From af739c357d2234c011fcf1d50bf08d4ff9cd7ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Cie=C5=9Blak?= Date: Thu, 30 Nov 2023 10:54:57 +0100 Subject: [PATCH] Discover: Add UI for connection test for Connect My Computer * Rename existing component to LegacyTestConnection * Set up new component with feature flag * Copy server TestConnection to Connect My Computer * ConnectionDiagnosticResult: Add prop for having step number and desc on same line This will be needed in connection test for Connect My Computer to make it consistent with the look of previous steps. * Adjust TestConnection to Connect My Computer, add story * Start test automatically if there's one login --- .../LegacyTestConnection.story.tsx | 201 ++++++++++ .../LegacyTestConnection.tsx | 235 ++++++++++++ .../LegacyTestConnection/index.ts | 17 + .../TestConnection/TestConnection.story.tsx | 222 +++++++---- .../TestConnection/TestConnection.tsx | 355 +++++++++++------- .../src/Discover/ConnectMyComputer/index.ts | 23 +- .../ConnectionDiagnosticResult.story.tsx | 8 + .../ConnectionDiagnosticResult.tsx | 18 +- .../useConnectionDiagnostic.ts | 24 +- .../services/storageService/storageService.ts | 9 + .../src/services/storageService/types.ts | 2 + 11 files changed, 867 insertions(+), 247 deletions(-) create mode 100644 web/packages/teleport/src/Discover/ConnectMyComputer/LegacyTestConnection/LegacyTestConnection.story.tsx create mode 100644 web/packages/teleport/src/Discover/ConnectMyComputer/LegacyTestConnection/LegacyTestConnection.tsx create mode 100644 web/packages/teleport/src/Discover/ConnectMyComputer/LegacyTestConnection/index.ts diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/LegacyTestConnection/LegacyTestConnection.story.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/LegacyTestConnection/LegacyTestConnection.story.tsx new file mode 100644 index 0000000000000..e144d4e693f77 --- /dev/null +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/LegacyTestConnection/LegacyTestConnection.story.tsx @@ -0,0 +1,201 @@ +/** + * 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 { LegacyTestConnection } from './LegacyTestConnection'; + +export default { + title: 'Teleport/Discover/ConnectMyComputer/LegacyTestConnection', + loaders: [mswLoader], +}; + +initialize(); + +const node = nodes[0]; + +const agentStepProps = { + prevStep: () => {}, + nextStep: () => {}, + agentMeta: { resourceName: node.hostname, node, agentMatcherLabels: [] }, +}; + +export const SingleLogin = () => { + return ( + + + + ); +}; + +SingleLogin.parameters = { + msw: { + handlers: [ + rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => + res(ctx.json({})) + ), + rest.get(cfg.api.connectMyComputerLoginsPath, (req, res, ctx) => + res(ctx.json({ logins: ['foo'] })) + ), + ], + }, +}; + +export const MultipleLogins = () => { + return ( + + + + ); +}; + +MultipleLogins.parameters = { + msw: { + handlers: [ + rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => + res(ctx.json({})) + ), + rest.get(cfg.api.connectMyComputerLoginsPath, (req, res, ctx) => + res(ctx.json({ logins: ['foo', 'bar', 'baz'] })) + ), + ], + }, +}; + +export const NoLogins = () => { + return ( + + + + ); +}; + +NoLogins.parameters = { + msw: { + handlers: [ + rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => + res(ctx.json({})) + ), + rest.get(cfg.api.connectMyComputerLoginsPath, (req, res, ctx) => + res(ctx.json({ logins: [] })) + ), + ], + }, +}; + +export const NoRole = () => { + return ( + + + + ); +}; + +NoRole.parameters = { + msw: { + handlers: [ + rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => + res(ctx.json({})) + ), + rest.get(cfg.api.connectMyComputerLoginsPath, (req, res, ctx) => + // TODO Check how our error responses look like. + res(ctx.status(404), ctx.text('Whoops no role found')) + ), + ], + }, +}; + +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/LegacyTestConnection/LegacyTestConnection.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/LegacyTestConnection/LegacyTestConnection.tsx new file mode 100644 index 0000000000000..9d27ee5368183 --- /dev/null +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/LegacyTestConnection/LegacyTestConnection.tsx @@ -0,0 +1,235 @@ +/** + * 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, + ButtonSecondary, + Flex, + Indicator, + Text, + Link, +} from 'design'; +import * as Icons from 'design/Icon'; +import { useAsync } from 'shared/hooks/useAsync'; +import { MenuLogin } from 'shared/components/MenuLogin'; +import * as connectMyComputer from 'shared/connectMyComputer'; + +import cfg from 'teleport/config'; +import useTeleport from 'teleport/useTeleport'; +import { + ActionButtons, + StyledBox, + Header, + TextIcon, +} from 'teleport/Discover/Shared'; +import { openNewTab } from 'teleport/lib/util'; +import { Node, sortNodeLogins } from 'teleport/services/nodes'; +import { ApiError } from 'teleport/services/api/parseError'; + +import { NodeMeta } from '../../useDiscover'; + +import type { AgentStepProps } from '../../types'; + +export const LegacyTestConnection = (props: AgentStepProps) => { + const { userService, storeUser } = useTeleport(); + 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 fetch the list of logins from that role. The user might have many logins + // available, but the Connect My Computer agent is always started by the system user that is + // running Connect. As such, the Connect My Computer role should include that valid login. + const [fetchLoginsAttempt, fetchLogins] = useAsync( + useCallback( + async (signal: AbortSignal) => { + await userService.reloadUser(signal); + + return await userService.fetchConnectMyComputerLogins(signal); + }, + [userService] + ) + ); + + useEffect(() => { + abortController.current = new AbortController(); + + if (fetchLoginsAttempt.status === '') { + fetchLogins(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. + + {(fetchLoginsAttempt.status === '' || + fetchLoginsAttempt.status === 'processing') && } + + {fetchLoginsAttempt.status === 'error' && + (fetchLoginsAttempt.error instanceof ApiError && + fetchLoginsAttempt.error.response.status === 404 ? ( + window.location.reload()} + > + <> + For Connect My Computer to work, the role{' '} + {connectMyComputer.getRoleNameForUser(storeUser.getUsername())}{' '} + must be assigned to you. Refresh this page to repeat the process + of enrolling a new resource and then{' '} + + restart the Connect My Computer setup + {' '} + in Teleport Connect. + + + ) : ( + fetchLogins(abortController.current.signal)} + > + <>Encountered Error: {fetchLoginsAttempt.statusText} + + ))} + + {fetchLoginsAttempt.status === 'success' && + (fetchLoginsAttempt.data.length > 0 ? ( + + ) : ( + window.location.reload()} + > + <> + The role{' '} + {connectMyComputer.getRoleNameForUser(storeUser.getUsername())}{' '} + does not contain any logins. It has likely been manually edited. + Refresh this page to repeat the process of enrolling a new + resource and then{' '} + + restart the Connect My Computer setup + {' '} + in Teleport Connect. + + + ))} + + + +
+ ); +}; + +const ErrorWithinStep = (props: { + buttonText: string; + buttonOnClick: () => void; + children: React.ReactNode; +}) => ( + <> + + + {props.children} + + + + {props.buttonText} + + +); + +const ConnectButton = ({ logins, node }: { logins: string[]; node: Node }) => { + if (logins.length === 1) { + return ( + + Connect + + ); + } + + return ( + { + return sortNodeLogins(logins).map(login => ({ + login, + url: cfg.getSshConnectRoute({ + clusterId: node.clusterId, + serverId: node.id, + login, + }), + })); + }} + onSelect={(event, login) => { + event.preventDefault(); + openNewTab( + cfg.getSshConnectRoute({ + clusterId: node.clusterId, + serverId: node.id, + login, + }) + ); + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + anchorOrigin={{ + vertical: 'center', + horizontal: 'right', + }} + /> + ); +}; diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/LegacyTestConnection/index.ts b/web/packages/teleport/src/Discover/ConnectMyComputer/LegacyTestConnection/index.ts new file mode 100644 index 0000000000000..351a32d4623de --- /dev/null +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/LegacyTestConnection/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 { LegacyTestConnection } from './LegacyTestConnection'; diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.story.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.story.tsx index a434554a9bc9e..4f9eb6c910e50 100644 --- a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.story.tsx +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.story.tsx @@ -1,11 +1,11 @@ /** - * Copyright 2023 Gravitational, Inc + * Copyright 2022 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 + * 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, @@ -19,48 +19,86 @@ 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 { nodes } from 'teleport/Nodes/fixtures'; import { createTeleportContext } from 'teleport/mocks/contexts'; -import { makeDefaultUserPreferences } from 'teleport/services/userPreferences/userPreferences'; +import { + DiscoverProvider, + DiscoverContextState, +} from 'teleport/Discover/useDiscover'; import { TestConnection } from './TestConnection'; export default { title: 'Teleport/Discover/ConnectMyComputer/TestConnection', loaders: [mswLoader], + parameters: { + msw: { + // All handlers within the story must be specified as keys in order to use Storybook's + // parameter inheritance to share handlers between stories. + // + // https://github.com/mswjs/msw-storybook-addon/tree/v1.10.0#composing-request-handlers + // https://storybook.js.org/docs/6.5/writing-stories/parameters#rules-of-parameter-inheritance + handlers: { + renewToken: rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => + res(ctx.json({})) + ), + mfaRequired: [ + rest.post(cfg.getMfaRequiredUrl(), (req, res, ctx) => + res(ctx.json({ required: false })) + ), + ], + connectionDiagnostic: [ + rest.post(cfg.getConnectionDiagnosticUrl(), (req, res, ctx) => + res( + ctx.json({ + id: '1234', + success: true, + traces: [ + { + traceType: 'rbac node', + status: 'success', + details: 'Everything is a-okay.', + }, + ], + }) + ) + ), + ], + }, + }, + }, }; initialize(); -const node = nodes[0]; - +const node = { ...nodes[0] }; +node.sshLogins = [ + ...node.sshLogins, + 'george_washington_really_long_name_testing', +]; const agentStepProps = { prevStep: () => {}, nextStep: () => {}, agentMeta: { resourceName: node.hostname, node, agentMatcherLabels: [] }, }; -export const SingleLogin = () => { - return ( - - - - ); -}; +export const SingleLogin = () => ( + + + +); SingleLogin.parameters = { msw: { - handlers: [ - rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => - res(ctx.json({})) - ), - rest.get(cfg.api.connectMyComputerLoginsPath, (req, res, ctx) => - res(ctx.json({ logins: ['foo'] })) - ), - ], + handlers: { + connectMyComputerLogins: [ + rest.get(cfg.api.connectMyComputerLoginsPath, (req, res, ctx) => + res(ctx.json({ logins: ['foo'] })) + ), + ], + }, }, }; @@ -74,14 +112,22 @@ export const MultipleLogins = () => { MultipleLogins.parameters = { msw: { - handlers: [ - rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => - res(ctx.json({})) - ), - rest.get(cfg.api.connectMyComputerLoginsPath, (req, res, ctx) => - res(ctx.json({ logins: ['foo', 'bar', 'baz'] })) - ), - ], + handlers: { + connectMyComputerLogins: [ + rest.get(cfg.api.connectMyComputerLoginsPath, (req, res, ctx) => + res( + ctx.json({ + logins: [ + 'foo', + 'bar', + 'baz', + 'czesława_maria_de_domo_cieślak_primo_voto_gospodarek_secundo_voto_kowalczyk', + ], + }) + ) + ), + ], + }, }, }; @@ -95,14 +141,13 @@ export const NoLogins = () => { NoLogins.parameters = { msw: { - handlers: [ - rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => - res(ctx.json({})) - ), - rest.get(cfg.api.connectMyComputerLoginsPath, (req, res, ctx) => - res(ctx.json({ logins: [] })) - ), - ], + handlers: { + connectMyComputerLogins: [ + rest.get(cfg.api.connectMyComputerLoginsPath, (req, res, ctx) => + res(ctx.json({ logins: [] })) + ), + ], + }, }, }; @@ -116,15 +161,16 @@ export const NoRole = () => { NoRole.parameters = { msw: { - handlers: [ - rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => - res(ctx.json({})) - ), - rest.get(cfg.api.connectMyComputerLoginsPath, (req, res, ctx) => - // TODO Check how our error responses look like. - res(ctx.status(404), ctx.text('Whoops no role found')) - ), - ], + handlers: { + connectMyComputerLogins: [ + rest.get(cfg.api.connectMyComputerLoginsPath, (req, res, ctx) => + res( + ctx.status(404), + ctx.json({ error: { message: 'No role found' } }) + ) + ), + ], + }, }, }; @@ -138,11 +184,13 @@ export const ReloadUserProcessing = () => { ReloadUserProcessing.parameters = { msw: { - handlers: [ - rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => - res(ctx.delay('infinite')) - ), - ], + handlers: { + renewToken: [ + rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => + res(ctx.delay('infinite')) + ), + ], + }, }, }; @@ -156,46 +204,54 @@ export const ReloadUserError = () => { ReloadUserError.parameters = { msw: { - handlers: [ + 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' }) - ) - ), - ], + renewToken: [ + 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({ error: { 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(); + const discoverCtx: DiscoverContextState = { + ...agentStepProps, + currentStep: 0, + onSelectResource: () => null, + resourceSpec: undefined, + exitFlow: () => null, + viewConfig: null, + indexedViews: [], + setResourceSpec: () => null, + updateAgentMeta: () => null, + emitErrorEvent: () => null, + emitEvent: () => null, + eventState: null, + }; return ( - - - {children} - + + + {children} + ); }; diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx index ba14c03b6ea77..8fd761b437ab7 100644 --- a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx @@ -1,11 +1,11 @@ /** - * Copyright 2023 Gravitational, Inc + * Copyright 2022 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 + * 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, @@ -14,40 +14,56 @@ * limitations under the License. */ -import React, { useEffect, useCallback, useRef } from 'react'; - +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { ButtonPrimary, ButtonSecondary, - Flex, - Indicator, Text, + Box, + LabelInput, + Indicator, Link, } from 'design'; -import * as Icons from 'design/Icon'; +import Select from 'shared/components/Select'; import { useAsync } from 'shared/hooks/useAsync'; -import { MenuLogin } from 'shared/components/MenuLogin'; +import * as Icons from 'design/Icon'; import * as connectMyComputer from 'shared/connectMyComputer'; import cfg from 'teleport/config'; import useTeleport from 'teleport/useTeleport'; +import ReAuthenticate from 'teleport/components/ReAuthenticate'; +import { openNewTab } from 'teleport/lib/util'; import { + useConnectionDiagnostic, + Header, ActionButtons, + HeaderSubtitle, + ConnectionDiagnosticResult, StyledBox, - Header, TextIcon, } from 'teleport/Discover/Shared'; -import { openNewTab } from 'teleport/lib/util'; -import { Node, sortNodeLogins } from 'teleport/services/nodes'; +import { sortNodeLogins } from 'teleport/services/nodes'; import { ApiError } from 'teleport/services/api/parseError'; import { NodeMeta } from '../../useDiscover'; +import type { Option } from 'shared/components/Select'; import type { AgentStepProps } from '../../types'; +import type { MfaAuthnResponse } from 'teleport/services/mfa'; -export const TestConnection = (props: AgentStepProps) => { +export function TestConnection(props: AgentStepProps) { const { userService, storeUser } = useTeleport(); - const meta = props.agentMeta as NodeMeta; + const { + runConnectionDiagnostic, + attempt: connectionDiagAttempt, + diagnosis, + nextStep, + canTestConnection, + showMfaDialog, + cancelMfaDialog, + } = useConnectionDiagnostic(); + const node = (props.agentMeta as NodeMeta).node; + const [selectedLoginOpt, setSelectedLoginOpt] = useState