diff --git a/web/packages/shared/components/ButtonSso/ButtonSso.tsx b/web/packages/shared/components/ButtonSso/ButtonSso.tsx index 83e6943064562..039a4efdc5117 100644 --- a/web/packages/shared/components/ButtonSso/ButtonSso.tsx +++ b/web/packages/shared/components/ButtonSso/ButtonSso.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import styled from 'styled-components'; import Button from 'design/Button'; -import { fade } from 'design/theme/utils/colorManipulator'; +import { darken, lighten } from 'design/theme/utils/colorManipulator'; import * as Icons from 'design/Icon'; import { AuthProviderType } from 'shared/services'; @@ -101,10 +101,12 @@ const StyledButton = styled(Button)` background-color: ${props => props.color}; display: block; width: 100%; + border: 1px solid transparent; &:hover, &:focus { - background: ${props => fade(props.color, 0.4)}; + background: ${props => darken(props.color, 0.1)}; + border: 1px solid ${props => lighten(props.color, 0.4)}; } height: 40px; position: relative; diff --git a/web/packages/teleport/src/components/FormLogin/FormLogin.tsx b/web/packages/teleport/src/components/FormLogin/FormLogin.tsx index 0139af18ecc1e..a3c0c4396a4fa 100644 --- a/web/packages/teleport/src/components/FormLogin/FormLogin.tsx +++ b/web/packages/teleport/src/components/FormLogin/FormLogin.tsx @@ -109,7 +109,12 @@ export default function LoginForm(props: Props) { ); } -const SsoList = ({ attempt, authProviders, onLoginWithSso }: Props) => { +const SsoList = ({ + attempt, + authProviders, + onLoginWithSso, + autoFocus = false, +}: Props) => { const { isProcessing } = attempt; return ( { isDisabled={isProcessing} providers={authProviders} onClick={onLoginWithSso} + autoFocus={autoFocus} /> ); }; -const Passwordless = ({ onLoginWithWebauthn, attempt }: Props) => { +const Passwordless = ({ + onLoginWithWebauthn, + attempt, + autoFocus = false, +}: Props) => { // Firefox currently does not support passwordless and when // logging in, it will return an ambigugous error. // We display a soft warning because firefox may provide @@ -144,6 +154,7 @@ const Passwordless = ({ onLoginWithWebauthn, attempt }: Props) => { width="100%" onClick={() => onLoginWithWebauthn()} disabled={attempt.isProcessing} + autoFocus={autoFocus} > @@ -171,6 +182,7 @@ const LocalForm = ({ onLoginWithWebauthn, clearAttempt, hasTransitionEnded, + autoFocus = false, }: Props & { hasTransitionEnded: boolean }) => { const { isProcessing } = attempt; const [pass, setPass] = useState(''); @@ -183,7 +195,7 @@ const LocalForm = ({ ); const usernameInputRef = useRefAutoFocus({ - shouldFocus: hasTransitionEnded, + shouldFocus: hasTransitionEnded && autoFocus, }); const [mfaType, setMfaType] = useState(mfaOptions[0]); @@ -233,8 +245,9 @@ const LocalForm = ({ value={user} onChange={e => setUser(e.target.value)} placeholder="Username" + mb={3} /> - + {auth2faType !== 'off' && ( - + ; - } - - if (otherProps.primaryAuthType === 'local') { - otherOptionsAvailable = otherProps.isPasswordlessEnabled || ssoEnabled; - $primary = ( - - ); - } - - if (otherProps.primaryAuthType === 'sso') { - $primary = ; + switch (otherProps.primaryAuthType) { + case 'passwordless': + $primary = ; + break; + case 'sso': + $primary = ; + break; + case 'local': + otherOptionsAvailable = otherProps.isPasswordlessEnabled || ssoEnabled; + $primary = ( + + ); + break; } return ( @@ -358,6 +377,11 @@ const Primary = ({ ); }; +// Secondary determines what other forms of authentication +// is allowed for the user to login with. +// +// There can be multiple authn types available, which will +// be visually separated by a divider. const Secondary = ({ prev, refCallback, @@ -366,50 +390,48 @@ const Secondary = ({ const ssoEnabled = otherProps.authProviders?.length > 0; const { primaryAuthType, isPasswordlessEnabled } = otherProps; - const $local = ; - const $sso = ; - const $passwordless = ; - let $secondary; - - if (primaryAuthType === 'passwordless') { - $secondary = ( - <> - {ssoEnabled && ( + switch (primaryAuthType) { + case 'passwordless': + if (ssoEnabled) { + $secondary = ( <> - {$sso} + + - )} - {$local} - - ); - } - - if (primaryAuthType === 'local') { - $secondary = ( - <> - {isPasswordlessEnabled && $passwordless} - {isPasswordlessEnabled && ssoEnabled && } - {ssoEnabled && $sso} - - ); - } - - if (primaryAuthType === 'sso') { - $secondary = ( - <> - {isPasswordlessEnabled && ( + ); + } else { + $secondary = ; + } + break; + case 'sso': + if (isPasswordlessEnabled) { + $secondary = ( <> - {$passwordless} + + - )} - {$local} - - ); + ); + } else { + $secondary = ; + } + break; + case 'local': + if (isPasswordlessEnabled) { + $secondary = ( + <> + + {otherProps.isPasswordlessEnabled && ssoEnabled && } + {ssoEnabled && } + + ); + } else { + $secondary = ; + } + break; } - return ( {$secondary} @@ -490,6 +512,7 @@ export type Props = { onLoginWithSso(provider: AuthProvider): void; onLoginWithWebauthn(creds?: UserCredentials): void; onLogin(username: string, password: string, token: string): void; + autoFocus?: boolean; }; type AttemptState = ReturnType[0]; diff --git a/web/packages/teleport/src/components/FormLogin/SsoButtons.tsx b/web/packages/teleport/src/components/FormLogin/SsoButtons.tsx index df40d168b27a5..8ad61a65307ea 100644 --- a/web/packages/teleport/src/components/FormLogin/SsoButtons.tsx +++ b/web/packages/teleport/src/components/FormLogin/SsoButtons.tsx @@ -15,15 +15,22 @@ limitations under the License. */ import React from 'react'; -import { Box } from 'design'; +import { Box, Text } from 'design'; import ButtonSso, { guessProviderType } from 'shared/components/ButtonSso'; import { AuthProvider } from 'shared/services'; -const SSOBtnList = ({ providers, prefixText, isDisabled, onClick }: Props) => { +const SSOBtnList = ({ + providers, + prefixText, + isDisabled, + onClick, + autoFocus = false, +}: Props) => { const $btns = providers.map((item, index) => { let { name, type, displayName } = item; const title = displayName || `${prefixText} ${name}`; const ssoType = guessProviderType(title, type); + const len = providers.length - 1; return ( { ssoType={ssoType} disabled={isDisabled} mt={3} + mb={index < len ? 3 : 0} + autoFocus={index === 0 && autoFocus} onClick={e => { e.preventDefault(); onClick(item); @@ -40,7 +49,11 @@ const SSOBtnList = ({ providers, prefixText, isDisabled, onClick }: Props) => { }); if ($btns.length === 0) { - return

You have no SSO providers configured

; + return ( + + You have no SSO providers configured + + ); } return ( @@ -55,6 +68,8 @@ type Props = { isDisabled: boolean; onClick(provider: AuthProvider): void; providers: AuthProvider[]; + // autoFocus focuses on the first button in list. + autoFocus?: boolean; }; export default SSOBtnList; diff --git a/web/packages/teleport/src/components/FormLogin/__snapshots__/FormLogin.story.test.tsx.snap b/web/packages/teleport/src/components/FormLogin/__snapshots__/FormLogin.story.test.tsx.snap index 2cb5099409415..5efd001bbf54d 100644 --- a/web/packages/teleport/src/components/FormLogin/__snapshots__/FormLogin.story.test.tsx.snap +++ b/web/packages/teleport/src/components/FormLogin/__snapshots__/FormLogin.story.test.tsx.snap @@ -7,7 +7,7 @@ exports[`auth2faType: off 1`] = ` .c5 { box-sizing: border-box; - margin-bottom: 24px; + margin-bottom: 16px; } .c8 { @@ -272,7 +272,7 @@ exports[`auth2faType: optional rendering 1`] = ` .c5 { box-sizing: border-box; - margin-bottom: 24px; + margin-bottom: 16px; } .c8 { @@ -705,7 +705,7 @@ exports[`auth2faType: otp rendering 1`] = ` .c5 { box-sizing: border-box; - margin-bottom: 24px; + margin-bottom: 16px; } .c8 { @@ -1164,7 +1164,7 @@ exports[`auth2faType: webauthn rendering 1`] = ` .c5 { box-sizing: border-box; - margin-bottom: 24px; + margin-bottom: 16px; } .c8 { @@ -1597,12 +1597,12 @@ exports[`cloud auth2faType: on rendering 1`] = ` .c5 { box-sizing: border-box; - margin-bottom: 24px; + margin-bottom: 16px; } .c8 { box-sizing: border-box; - margin-bottom: 8px; + margin-bottom: 4px; } .c9 { @@ -1618,7 +1618,7 @@ exports[`cloud auth2faType: on rendering 1`] = ` .c13 { box-sizing: border-box; - margin-bottom: 16px; + margin-bottom: 8px; } .c15 { @@ -2230,7 +2230,7 @@ exports[`server error rendering 1`] = ` .c6 { box-sizing: border-box; - margin-bottom: 24px; + margin-bottom: 16px; } .c9 { @@ -2527,6 +2527,7 @@ exports[`sso list still renders when local auth is disabled 1`] = ` min-height: 32px; font-size: 12px; padding: 0px 24px; + margin-bottom: 16px; margin-top: 16px; width: 100%; } @@ -2549,6 +2550,53 @@ exports[`sso list still renders when local auth is disabled 1`] = ` color: rgba(255,255,255,0.3); } +.c8 { + line-height: 1.5; + margin: 0; + display: inline-flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + border: none; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-weight: 600; + outline: none; + position: relative; + text-align: center; + text-decoration: none; + text-transform: uppercase; + transition: all 0.3s; + -webkit-font-smoothing: antialiased; + background: #512FC9; + color: rgba(255,255,255,0.87); + min-height: 32px; + font-size: 12px; + padding: 0px 24px; + margin-bottom: 0px; + margin-top: 16px; + width: 100%; +} + +.c8:active { + opacity: 0.56; +} + +.c8:hover, +.c8:focus { + background: #651FFF; +} + +.c8:active { + background: #354AA4; +} + +.c8:disabled { + background: rgba(255,255,255,0.12); + color: rgba(255,255,255,0.3); +} + .c7 { display: inline-block; transition: color 0.3s; @@ -2584,6 +2632,7 @@ exports[`sso list still renders when local auth is disabled 1`] = ` background-color: #444444; display: block; width: 100%; + border: 1px solid transparent; height: 40px; position: relative; box-sizing: border-box; @@ -2591,7 +2640,8 @@ exports[`sso list still renders when local auth is disabled 1`] = ` .c4:hover, .c4:focus { - background: rgba(68,68,68,0.4); + background: rgb(61,61,61); + border: 1px solid rgb(142,142,142); } .c4 .c6 { @@ -2599,21 +2649,23 @@ exports[`sso list still renders when local auth is disabled 1`] = ` opacity: 0.87; } -.c8 { +.c9 { background-color: #dd4b39; display: block; width: 100%; + border: 1px solid transparent; height: 40px; position: relative; box-sizing: border-box; } -.c8:hover, -.c8:focus { - background: rgba(221,75,57,0.4); +.c9:hover, +.c9:focus { + background: rgb(198,67,51); + border: 1px solid rgb(234,147,136); } -.c8 .c6 { +.c9 .c6 { font-size: 20px; opacity: 0.87; } @@ -2663,7 +2715,7 @@ exports[`sso list still renders when local auth is disabled 1`] = ` Login with github
+ ); + })} + + + + ); +} + +function PromptPin({ onCancel, onUserResponse, processing }: Props) { + const [pin, setPin] = React.useState(''); + + return ( + + {({ validator }) => ( +
{ + e.preventDefault(); + validator.validate() && onUserResponse(pin); + }} + > + + setPin(e.target.value.trim())} + placeholder="1234" + autoFocus + /> + + + + )} +
+ ); +} + +function ActionButtons({ + onCancel, + nextButton = { isVisible: false, isDisabled: false }, +}: { + onCancel(): void; + nextButton?: { + isVisible: boolean; + isDisabled: boolean; + }; +}) { + return ( + + + Cancel + + {/* The caller of this component needs to handle wrapping + this in a
element to handle `onSubmit` event on enter key*/} + {nextButton.isVisible && ( + + Next + + )} + + ); +} + +const requiredLength = value => () => { + if (!value || value.length < 4) { + return { + valid: false, + message: 'pin must be at least 4 characters', + }; + } + + return { + valid: true, + }; +}; + +export type Props = WebauthnLogin & { + onCancel(): void; +}; diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptHardwareKey/hardware.svg b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptWebauthn/hardware.svg similarity index 100% rename from web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptHardwareKey/hardware.svg rename to web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptWebauthn/hardware.svg diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptHardwareKey/index.ts b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptWebauthn/index.ts similarity index 86% rename from web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptHardwareKey/index.ts rename to web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptWebauthn/index.ts index 6dd0745e33377..71ecb48beead2 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptHardwareKey/index.ts +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/PromptWebauthn/index.ts @@ -14,5 +14,4 @@ See the License for the specific language governing permissions and limitations under the License. */ -import PromptHardwareKey from './PromptHardwareKey'; -export default PromptHardwareKey; +export { PromptWebauthn } from './PromptWebauthn'; diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.ts b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.ts index 42b4b6ea9f01a..35238ca9c9828 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.ts +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.ts @@ -15,18 +15,21 @@ */ import { useState, useEffect, useRef } from 'react'; + +import { useAsync } from 'shared/hooks/useAsync'; + import * as types from 'teleterm/ui/services/clusters/types'; import { useAppContext } from 'teleterm/ui/appContextProvider'; -import { useAsync } from 'shared/hooks/useAsync'; +import { assertUnreachable } from 'teleterm/ui/utils'; export default function useClusterLogin(props: Props) { const { onSuccess, clusterUri } = props; const { clustersService } = useAppContext(); const cluster = clustersService.findCluster(clusterUri); const refAbortCtrl = useRef(null); - const [shouldPromptSsoStatus, promptSsoStatus] = useState(false); - const [shouldPromptHardwareKey, promptHardwareKey] = useState(false); const loggedInUserName = cluster.loggedInUser?.name || null; + const [shouldPromptSsoStatus, promptSsoStatus] = useState(false); + const [webauthnLogin, setWebauthnLogin] = useState(); const [initAttempt, init] = useAsync(async () => { const authSettings = await clustersService.getAuthSettings(clusterUri); @@ -40,24 +43,83 @@ export default function useClusterLogin(props: Props) { return authSettings; }); - const [loginAttempt, login] = useAsync((opts: types.LoginParams) => { - refAbortCtrl.current = clustersService.client.createAbortController(); - return clustersService.login(opts, refAbortCtrl.current.signal); - }); + const [loginAttempt, login, setAttempt] = useAsync( + (params: types.LoginParams) => { + refAbortCtrl.current = clustersService.client.createAbortController(); + switch (params.kind) { + case 'local': + return clustersService.loginLocal( + params, + refAbortCtrl.current.signal + ); + case 'passwordless': + return clustersService.loginPasswordless( + params, + refAbortCtrl.current.signal + ); + case 'sso': + return clustersService.loginSso(params, refAbortCtrl.current.signal); + default: + assertUnreachable(params); + } + } + ); const onLoginWithLocal = ( - username: '', - password: '', - token: '', - authType?: types.Auth2faType + username: string, + password: string, + token: string, + secondFactor?: types.Auth2faType ) => { - promptHardwareKey(authType === 'webauthn'); + if (secondFactor === 'webauthn') { + setWebauthnLogin({ prompt: 'tap' }); + } + login({ + kind: 'local', clusterUri, - local: { - username, - password, - token, + username, + password, + token, + }); + }; + + const onLoginWithPasswordless = () => { + login({ + kind: 'passwordless', + clusterUri, + onPromptCallback: (prompt: types.WebauthnLoginPrompt) => { + const newLogin: WebauthnLogin = { + prompt: prompt.type, + processing: false, + }; + + if (prompt.type === 'pin') { + newLogin.onUserResponse = (pin: string) => { + setWebauthnLogin({ + ...newLogin, + // prevent user from clicking on submit buttons more than once + processing: true, + }); + prompt.onUserResponse(pin); + }; + } + + if (prompt.type === 'credential') { + newLogin.loginUsernames = prompt.data.credentials.map( + c => c.username + ); + newLogin.onUserResponse = (index: number) => { + setWebauthnLogin({ + ...newLogin, + // prevent user from clicking on multiple usernames + processing: true, + }); + prompt.onUserResponse(index); + }; + } + + setWebauthnLogin(newLogin); }, }); }; @@ -65,11 +127,10 @@ export default function useClusterLogin(props: Props) { const onLoginWithSso = (provider: types.AuthProvider) => { promptSsoStatus(true); login({ + kind: 'sso', clusterUri, - sso: { - providerName: provider.name, - providerType: provider.type, - }, + providerName: provider.name, + providerType: provider.type, }); }; @@ -82,13 +143,19 @@ export default function useClusterLogin(props: Props) { props.onCancel(); }; + // Since the login form can have two views (primary and secondary) + // we need to clear any rendered error dialogs before switching. + const clearLoginAttempt = () => { + setAttempt({ status: '', statusText: '', data: null }); + }; + useEffect(() => { init(); }, []); useEffect(() => { if (loginAttempt.status !== 'processing') { - promptHardwareKey(false); + setWebauthnLogin(null); promptSsoStatus(false); } @@ -99,15 +166,17 @@ export default function useClusterLogin(props: Props) { return { shouldPromptSsoStatus, - shouldPromptHardwareKey, + webauthnLogin, title: cluster?.name, loggedInUserName, onLoginWithLocal, + onLoginWithPasswordless, onLoginWithSso, onCloseDialog, onAbort, loginAttempt, initAttempt, + clearLoginAttempt, }; } @@ -118,3 +187,11 @@ export type Props = { onCancel(): void; onSuccess?(): void; }; + +export type WebauthnLogin = { + prompt: types.WebauthnLoginPrompt['type']; + // The below fields are only ever used for passwordless login flow. + processing?: boolean; + loginUsernames?: string[]; + onUserResponse?(val: number | string): void; +}; diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx index 97e3e9c031fe9..b02f8258889ac 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx @@ -1,11 +1,15 @@ import React from 'react'; import { ButtonIcon, Flex, Text } from 'design'; import { Trash, Unlink } from 'design/Icon'; + import { ExtendedTrackedConnection } from 'teleterm/ui/services/connectionTracker'; import { ListItem } from 'teleterm/ui/components/ListItem'; -import { ConnectionStatusIndicator } from './ConnectionStatusIndicator'; +import { assertUnreachable } from 'teleterm/ui/utils'; + import { useKeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation'; +import { ConnectionStatusIndicator } from './ConnectionStatusIndicator'; + interface ConnectionItemProps { index: number; item: ExtendedTrackedConnection; @@ -128,7 +132,3 @@ function getKindName(kind: ExtendedTrackedConnection['kind']): string { assertUnreachable(kind); } } - -function assertUnreachable(x: never): never { - throw new Error(`Unhandled case: ${x}`); -} diff --git a/web/packages/teleterm/src/ui/services/clusters/clustersService.test.ts b/web/packages/teleterm/src/ui/services/clusters/clustersService.test.ts index 7fdf45e85dad3..82c5213aa0a92 100644 --- a/web/packages/teleterm/src/ui/services/clusters/clustersService.test.ts +++ b/web/packages/teleterm/src/ui/services/clusters/clustersService.test.ts @@ -96,7 +96,9 @@ function createService( function getClientMocks(): Partial { return { - login: jest.fn().mockResolvedValueOnce(undefined), + loginLocal: jest.fn().mockResolvedValueOnce(undefined), + loginSso: jest.fn().mockResolvedValueOnce(undefined), + loginPasswordless: jest.fn().mockResolvedValueOnce(undefined), logout: jest.fn().mockResolvedValueOnce(undefined), addRootCluster: jest.fn().mockResolvedValueOnce(clusterMock), removeCluster: jest.fn().mockResolvedValueOnce(undefined), @@ -177,16 +179,19 @@ test('login into cluster and sync resources', async () => { const client = getClientMocks(); const service = createService(client, new NotificationsServiceMock()); const loginParams = { + kind: 'local' as const, clusterUri, - local: { username: 'admin', password: 'admin', token: '1234' }, + username: 'admin', + password: 'admin', + token: '1234', }; // Add mocked gateway to service state. await service.syncGateways(); - await service.login(loginParams, undefined); + await service.loginLocal(loginParams, undefined); - expect(client.login).toHaveBeenCalledWith(loginParams, undefined); + expect(client.loginLocal).toHaveBeenCalledWith(loginParams, undefined); expect(client.listGateways).toHaveBeenCalledWith(); expect(client.listDatabases).toHaveBeenCalledWith(clusterUri); expect(client.listServers).toHaveBeenCalledWith(clusterUri); diff --git a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts index 216f5dfb6225c..68d0770057eca 100644 --- a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts +++ b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts @@ -14,7 +14,9 @@ import { AuthSettings, ClustersServiceState, CreateGatewayParams, - LoginParams, + LoginLocalParams, + LoginSsoParams, + LoginPasswordlessParams, SyncStatus, tsh, } from './types'; @@ -62,16 +64,41 @@ export class ClustersService extends ImmutableStore { this.removeResources(clusterUri); } - async login(params: LoginParams, abortSignal: tsh.TshAbortSignal) { - await this.client.login(params, abortSignal); + async loginLocal(params: LoginLocalParams, abortSignal: tsh.TshAbortSignal) { + await this.client.loginLocal(params, abortSignal); + await this.syncRootClusterAndRestartClusterGatewaysAndCatchErrors( + params.clusterUri + ); + } + + async loginSso(params: LoginSsoParams, abortSignal: tsh.TshAbortSignal) { + await this.client.loginSso(params, abortSignal); + await this.syncRootClusterAndRestartClusterGatewaysAndCatchErrors( + params.clusterUri + ); + } + + async loginPasswordless( + params: LoginPasswordlessParams, + abortSignal: tsh.TshAbortSignal + ) { + await this.client.loginPasswordless(params, abortSignal); + await this.syncRootClusterAndRestartClusterGatewaysAndCatchErrors( + params.clusterUri + ); + } + + private async syncRootClusterAndRestartClusterGatewaysAndCatchErrors( + clusterUri: string + ) { await Promise.allSettled([ - this.syncRootClusterAndCatchErrors(params.clusterUri), + this.syncRootClusterAndCatchErrors(clusterUri), // A temporary workaround until the gateways are able to refresh their own certs on incoming // connections. // // After logging in and obtaining fresh certs for the cluster, we need to make the gateways // obtain fresh certs as well. Currently, the only way to achieve that is to restart them. - this.restartClusterGatewaysAndCatchErrors(params.clusterUri).then(() => + this.restartClusterGatewaysAndCatchErrors(clusterUri).then(() => // Sync gateways to update their status, in case one of them failed to start back up. // In that case, that gateway won't be included in the gateway list in the tsh daemon. this.syncGateways() diff --git a/web/packages/teleterm/src/ui/services/clusters/types.ts b/web/packages/teleterm/src/ui/services/clusters/types.ts index c584128b06a41..6751bbd913b0f 100644 --- a/web/packages/teleterm/src/ui/services/clusters/types.ts +++ b/web/packages/teleterm/src/ui/services/clusters/types.ts @@ -30,9 +30,24 @@ export type Auth2faType = shared.Auth2faType; export type AuthProviderType = shared.AuthProviderType; +export type PrimaryAuthType = shared.PrimaryAuthType; + +export type AuthType = shared.AuthType; + export type AuthProvider = tsh.AuthProvider; -export type LoginParams = tsh.LoginParams; +export type LoginLocalParams = { kind: 'local' } & tsh.LoginLocalParams; + +export type LoginPasswordlessParams = { + kind: 'passwordless'; +} & tsh.LoginPasswordlessParams; + +export type LoginSsoParams = { kind: 'sso' } & tsh.LoginSsoParams; + +export type LoginParams = + | LoginLocalParams + | LoginPasswordlessParams + | LoginSsoParams; export type Application = tsh.Application; @@ -48,9 +63,16 @@ export type Kube = tsh.Kube; export type Database = tsh.Database; +export type LoginPasswordlessRequest = tsh.LoginPasswordlessRequest; + +export type WebauthnLoginPrompt = tsh.WebauthnLoginPrompt; + export interface AuthSettings extends tsh.AuthSettings { secondFactor: Auth2faType; preferredMfa: PreferredMfaType; + authType: AuthType; + allowPasswordless: boolean; + localConnectorName: string; } export { tsh }; diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts index 4693b0fda97b5..93012de426ed9 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts @@ -1,4 +1,6 @@ import { routing } from 'teleterm/ui/uri'; +import { assertUnreachable } from 'teleterm/ui/utils'; + import { Document } from './types'; export function getResourceUri(document: Document): string { @@ -22,7 +24,3 @@ export function getResourceUri(document: Document): string { assertUnreachable(document); } } - -function assertUnreachable(x: never): never { - throw new Error(`Unhandled case: ${x}`); -} diff --git a/web/packages/teleterm/src/ui/utils/assertUnreachable.ts b/web/packages/teleterm/src/ui/utils/assertUnreachable.ts new file mode 100644 index 0000000000000..0b9aed7fc3b88 --- /dev/null +++ b/web/packages/teleterm/src/ui/utils/assertUnreachable.ts @@ -0,0 +1,19 @@ +/** + * 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 + * + * 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 function assertUnreachable(x: never): never { + throw new Error(`Unhandled case: ${x}`); +} diff --git a/web/packages/teleterm/src/ui/utils/index.ts b/web/packages/teleterm/src/ui/utils/index.ts index 54c2d03271aa5..b1bd20f808e26 100644 --- a/web/packages/teleterm/src/ui/utils/index.ts +++ b/web/packages/teleterm/src/ui/utils/index.ts @@ -1,3 +1,4 @@ export * from './uid'; export * from './retryWithRelogin'; export * from './getUserWithClusterName'; +export * from './assertUnreachable';