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