diff --git a/web/packages/build/package.json b/web/packages/build/package.json
index 60e0b0b31d2dc..4767f347684e7 100644
--- a/web/packages/build/package.json
+++ b/web/packages/build/package.json
@@ -87,6 +87,7 @@
"react-dom": "^18.2.0",
"react-is": "^16.8.0",
"react-refresh": "^0.14.0",
+ "react-select-event": "^5.5.1",
"react-test-renderer": "^18.2.0",
"react-transition-group": "^4.4.2",
"rollup-plugin-visualizer": "^5.9.0",
diff --git a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx
index a31e389846f7d..4c05043708175 100644
--- a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx
+++ b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx
@@ -21,7 +21,6 @@ import Box from 'design/Box';
import { ButtonPrimary, ButtonSecondary } from 'design/Button';
import Dialog from 'design/Dialog';
import Flex from 'design/Flex';
-import * as Icon from 'design/Icon';
import Image from 'design/Image';
import Indicator from 'design/Indicator';
import { RadioGroup } from 'design/RadioGroup';
@@ -35,7 +34,8 @@ import { useAsync } from 'shared/hooks/useAsync';
import useAttempt from 'shared/hooks/useAttemptNext';
import { Auth2faType } from 'shared/services';
import createMfaOptions, { MfaOption } from 'shared/utils/createMfaOptions';
-import styled from 'styled-components';
+
+import { PasskeyIcons } from 'teleport/components/PasskeyIcons';
import { DialogHeader } from 'teleport/Account/DialogHeader';
import useReAuthenticate from 'teleport/components/ReAuthenticate/useReAuthenticate';
@@ -517,18 +517,7 @@ function PasskeyBlurb() {
borderRadius={3}
p={3}
>
-
-
-
-
-
-
-
-
-
-
-
-
+
Teleport supports passkeys, a password replacement that validates your
identity using touch, facial recognition, a device password, or a PIN.
@@ -540,12 +529,3 @@ function PasskeyBlurb() {
);
}
-
-const OverlappingChip = styled.span`
- display: inline-block;
- background: ${props => props.theme.colors.levels.surface};
- border: ${props => props.theme.borders[1]};
- border-color: ${props => props.theme.colors.interactive.tonal.neutral[2]};
- border-radius: 50%;
- margin-right: -6px;
-`;
diff --git a/web/packages/teleport/src/Login/Login.test.tsx b/web/packages/teleport/src/Login/Login.test.tsx
index 6bb8318933214..4a3247783f7d0 100644
--- a/web/packages/teleport/src/Login/Login.test.tsx
+++ b/web/packages/teleport/src/Login/Login.test.tsx
@@ -17,6 +17,8 @@
*/
import React from 'react';
+import { userEvent, UserEvent } from '@testing-library/user-event';
+import selectEvent from 'react-select-event';
import { render, fireEvent, screen, waitFor } from 'design/utils/testing';
import auth from 'teleport/services/auth/auth';
@@ -25,10 +27,13 @@ import cfg from 'teleport/config';
import { Login } from './Login';
+let user: UserEvent;
+
beforeEach(() => {
jest.restoreAllMocks();
jest.spyOn(history, 'push').mockImplementation();
jest.spyOn(history, 'getRedirectParam').mockImplementation(() => '/');
+ user = userEvent.setup();
});
test('basic rendering', () => {
@@ -58,6 +63,34 @@ test('login with redirect', async () => {
expect(history.push).toHaveBeenCalledWith('http://localhost/web', true);
});
+test('login with MFA, changing method to OTP', async () => {
+ jest.spyOn(cfg, 'getAuth2faType').mockImplementation(() => 'optional');
+ jest.spyOn(auth, 'login').mockResolvedValue(null);
+
+ render();
+
+ // fill form
+ const username = screen.getByLabelText(/username/i);
+ const password = screen.getByLabelText(/password/i);
+ const mfaType = screen.getByLabelText(/multi-factor type/i);
+ await user.type(username, 'username');
+ await user.type(password, '123');
+
+ expect(
+ screen.queryByLabelText(/authenticator code/i)
+ ).not.toBeInTheDocument();
+ await selectEvent.select(mfaType, 'Authenticator App');
+ const authCode = screen.getByLabelText(/authenticator code/i);
+ await user.type(authCode, '987654');
+
+ // test login and redirect
+ fireEvent.click(screen.getByText('Sign In'));
+ await waitFor(() => {
+ expect(auth.login).toHaveBeenCalledWith('username', '123', '987654');
+ });
+ expect(history.push).toHaveBeenCalledWith('http://localhost/web', true);
+});
+
test('login with SSO', () => {
jest.spyOn(cfg, 'getAuth2faType').mockImplementation(() => 'otp');
jest.spyOn(cfg, 'getPrimaryAuthType').mockImplementation(() => 'sso');
@@ -80,6 +113,19 @@ test('login with SSO', () => {
);
});
+test('passwordless login', async () => {
+ jest.spyOn(cfg, 'getPrimaryAuthType').mockReturnValue('passwordless');
+ jest.spyOn(auth, 'loginWithWebauthn').mockResolvedValue(undefined);
+
+ render();
+
+ await user.click(
+ screen.getByRole('button', { name: 'Sign in with a Passkey' })
+ );
+ expect(auth.loginWithWebauthn).toHaveBeenCalledWith(undefined); // No credentials
+ expect(history.push).toHaveBeenCalledWith('http://localhost/web', true);
+});
+
describe('test MOTD', () => {
test('show motd only if motd is set', async () => {
// default login form
diff --git a/web/packages/teleport/src/components/FormLogin/FormLogin.story.tsx b/web/packages/teleport/src/components/FormLogin/FormLogin.story.tsx
index 6da0ee11b2ad9..049cbb04fd5a8 100644
--- a/web/packages/teleport/src/components/FormLogin/FormLogin.story.tsx
+++ b/web/packages/teleport/src/components/FormLogin/FormLogin.story.tsx
@@ -21,7 +21,6 @@ import React from 'react';
import FormLogin, { Props } from './FormLogin';
const props: Props = {
- title: 'Custom Title',
attempt: {
isFailed: false,
isSuccess: false,
@@ -74,7 +73,6 @@ export const LocalWithOnAndPwdless = () => (
export const Cloud = () => (
null}
@@ -89,7 +87,7 @@ export const ServerError = () => {
'invalid credentials with looooooooooooooooooooooooooooooooong text',
};
- return ;
+ return ;
};
export const LocalWithSso = () => {
@@ -98,7 +96,7 @@ export const LocalWithSso = () => {
{ name: 'google', type: 'oidc', url: '' } as const,
];
- return ;
+ return ;
};
export const LocalWithSsoAndPwdless = () => {
@@ -114,7 +112,6 @@ export const LocalWithSsoAndPwdless = () => {
return (
@@ -130,7 +127,6 @@ export const LocalDisabledWithSso = () => {
return (
@@ -138,7 +134,7 @@ export const LocalDisabledWithSso = () => {
};
export const LocalDisabledNoSso = () => (
-
+
);
export const PrimarySso = () => {
@@ -160,12 +156,7 @@ export const PrimarySso = () => {
];
return (
-
+
);
};
@@ -178,7 +169,6 @@ export const PrimarySsoWithPwdless = () => {
return (
{
return (
{
return (
{
return (
diff --git a/web/packages/teleport/src/components/FormLogin/FormLogin.test.tsx b/web/packages/teleport/src/components/FormLogin/FormLogin.test.tsx
index 02369d39c7e28..2d8e2e16dcb55 100644
--- a/web/packages/teleport/src/components/FormLogin/FormLogin.test.tsx
+++ b/web/packages/teleport/src/components/FormLogin/FormLogin.test.tsx
@@ -41,7 +41,7 @@ test('primary username and password with mfa off', () => {
target: { value: '123' },
});
- fireEvent.click(screen.getByText(/sign in/i));
+ fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(onLogin).toHaveBeenCalledWith('username', '123', '');
});
@@ -64,7 +64,7 @@ test('auth2faType: otp', () => {
fireEvent.change(screen.getByPlaceholderText(/123 456/i), {
target: { value: '456' },
});
- fireEvent.click(screen.getByText(/sign in/i));
+ fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(onLogin).toHaveBeenCalledWith('username', '123', '456');
});
@@ -91,7 +91,7 @@ test('auth2faType: webauthn', async () => {
target: { value: '123' },
});
- fireEvent.click(screen.getByText(/sign in/i));
+ fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(onLoginWithWebauthn).toHaveBeenCalledWith({
username: 'username',
password: '123',
@@ -113,7 +113,7 @@ test('input validation error handling', async () => {
/>
);
- fireEvent.click(screen.getByText(/sign in/i));
+ fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(onLogin).not.toHaveBeenCalled();
expect(onLoginWithSso).not.toHaveBeenCalled();
diff --git a/web/packages/teleport/src/components/FormLogin/FormLogin.tsx b/web/packages/teleport/src/components/FormLogin/FormLogin.tsx
index 4244ecdec27ab..ac0e908fce85c 100644
--- a/web/packages/teleport/src/components/FormLogin/FormLogin.tsx
+++ b/web/packages/teleport/src/components/FormLogin/FormLogin.tsx
@@ -26,8 +26,9 @@ import {
ButtonPrimary,
Box,
ButtonText,
+ ButtonSecondary,
+ Button,
} from 'design';
-import { Key, ArrowForward } from 'design/Icon';
import * as Alerts from 'design/Alert';
import {
AuthProvider,
@@ -48,67 +49,63 @@ import { StepSlider, StepComponentProps } from 'design/StepSlider';
import { UserCredentials } from 'teleport/services/auth';
+import { PasskeyIcons } from '../PasskeyIcons';
+
import SSOButtonList from './SsoButtons';
+const allAuthTypes: PrimaryAuthType[] = ['passwordless', 'sso', 'local'];
+
export default function LoginForm(props: Props) {
const {
- title,
attempt,
isLocalAuthEnabled = true,
+ isPasswordlessEnabled,
authProviders = [],
+ primaryAuthType,
} = props;
const ssoEnabled = authProviders?.length > 0;
// If local auth was not enabled, disregard any primary auth type config
// and display sso providers if any.
- if (!isLocalAuthEnabled && ssoEnabled) {
- return (
-
-
- {title}
-
- {attempt.isFailed && (
-
- {attempt.message}
-
- )}
-
-
- );
- }
+ const actualPrimaryType = isLocalAuthEnabled ? primaryAuthType : 'sso';
- if (!isLocalAuthEnabled) {
- return (
-
-
- {title}
-
- Login has not been enabled
-
- The ability to login has not been enabled. Please contact your system
- administrator for more information.
-
-
- );
+ const allowedAuthTypes = allAuthTypes.filter(t => {
+ if (!isLocalAuthEnabled) return ssoEnabled && t === 'sso';
+ if (!isPasswordlessEnabled && t === 'passwordless') return false;
+ if (!ssoEnabled && t === 'sso') return false;
+ return true;
+ });
+ const otherAuthTypes = allowedAuthTypes.filter(t => t !== actualPrimaryType);
+
+ let errorMessage = '';
+ if (allowedAuthTypes.length === 0) {
+ errorMessage = 'Login has not been enabled';
+ } else if (attempt.isFailed) {
+ errorMessage = attempt.message;
}
// Everything below requires local auth to be enabled.
return (
-
-
- {title}
+
+
+ Sign in to Teleport
- {attempt.isFailed && (
-
- {attempt.message}
-
+ {errorMessage && {errorMessage}}
+ {allowedAuthTypes.length > 0 ? (
+
+ flows={loginViews}
+ currFlow={'default'}
+ otherAuthTypes={otherAuthTypes}
+ {...props}
+ primaryAuthType={actualPrimaryType}
+ />
+ ) : (
+
+ The ability to login has not been enabled. Please contact your system
+ administrator for more information.
+
)}
-
- flows={loginViews}
- currFlow={'default'}
- {...props}
- />
);
}
@@ -126,7 +123,6 @@ const SsoList = ({
const { isProcessing } = attempt;
return (
{
+ primary,
+}: Props & { hasTransitionEnded: boolean; primary: boolean }) => {
const ref = useRefAutoFocus({
shouldFocus: hasTransitionEnded && autoFocus,
});
// Firefox currently does not support passwordless and when
- // logging in, it will return an ambigugous error.
+ // logging in, it will return an ambiguous error.
// We display a soft warning because firefox may provide
// support in the near future: https://github.com/gravitational/webapps/pull/876
const isFirefox = window.navigator?.userAgent
?.toLowerCase()
.includes('firefox');
return (
-
+
{isFirefox && (
Firefox may not support passwordless login. Please try Chrome or
Safari.
)}
- onLoginWithWebauthn()}
- disabled={attempt.isProcessing}
+
-
-
-
-
- Passwordless
-
- Follow the prompt from your browser
-
-
-
-
-
-
+
+
+
+ Your browser will prompt you for a device key.
+
+
+
+
);
};
@@ -247,8 +242,6 @@ const LocalForm = ({
{({ validator }) => (
onSetMfaOption(opt as MfaOption, validator)}
@@ -303,10 +296,13 @@ const LocalForm = ({
mb={0}
isDisabled={isProcessing}
menuIsOpen={true}
+ // Needed to prevent the menu from causing scroll bars to
+ // appear.
+ menuPosition="fixed"
/>
{mfaType.value === 'otp' && (
onLoginClick(e, validator)}
@@ -346,127 +340,76 @@ const LocalForm = ({
);
};
-// Primary determines which authentication type to display
-// on initial render of the login form.
-const Primary = ({
+// Displays the primary login options and a list of secondary options.
+const LoginOptions = ({
next,
refCallback,
- hasTransitionEnded,
+ otherAuthTypes,
...otherProps
-}: Props & StepComponentProps) => {
- const ssoEnabled = otherProps.authProviders?.length > 0;
- let otherOptionsAvailable = true;
- let $primary;
-
- switch (otherProps.primaryAuthType) {
- case 'passwordless':
- $primary = (
- {
+ return (
+
+
+ {otherAuthTypes.length > 0 && }
+ {otherAuthTypes.map(authType => (
+
+ ))}
+
+ );
+};
+
+function AuthMethod({
+ authType,
+ primary,
+ autoFocus,
+ next,
+ ...otherProps
+}: {
+ authType: PrimaryAuthType;
+ primary?: boolean;
+} & Props &
+ StepComponentProps) {
+ switch (authType) {
+ case 'passwordless':
+ return (
+
);
- break;
case 'sso':
- $primary = (
-
- );
- break;
+ return ;
case 'local':
- otherOptionsAvailable = otherProps.isPasswordlessEnabled || ssoEnabled;
- $primary = (
-
+ return primary ? (
+
+ ) : (
+
+
+ Sign in with username and password
+
+
);
- break;
}
+}
- return (
-
- {$primary}
- {otherOptionsAvailable && (
-
- {
- otherProps.clearAttempt();
- next();
- }}
- >
- Other sign-in options
-
-
- )}
-
- );
-};
-
-// 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 = ({
+// Displays a standalone local login form.
+const LocalLogin = ({
prev,
refCallback,
...otherProps
}: Props & StepComponentProps) => {
- const ssoEnabled = otherProps.authProviders?.length > 0;
- const { primaryAuthType, isPasswordlessEnabled } = otherProps;
-
- let $secondary;
- switch (primaryAuthType) {
- case 'passwordless':
- if (ssoEnabled) {
- $secondary = (
- <>
-
-
-
- >
- );
- } else {
- $secondary = ;
- }
- break;
- case 'sso':
- if (isPasswordlessEnabled) {
- $secondary = (
- <>
-
-
-
- >
- );
- } else {
- $secondary = ;
- }
- break;
- case 'local':
- if (isPasswordlessEnabled) {
- $secondary = (
- <>
-
- {otherProps.isPasswordlessEnabled && ssoEnabled && }
- {ssoEnabled && }
- >
- );
- } else {
- $secondary = ;
- }
- break;
- }
return (
-
- {$secondary}
+
+
(
flexDirection="column"
borderBottom={1}
borderColor="text.muted"
- mx={5}
- mt={5}
- mb={2}
+ my={3}
>
Or
);
-const StyledPaswordlessBtn = styled(ButtonText)`
- display: block;
- text-align: left;
- border: 1px solid ${({ theme }) => theme.colors.buttons.border.border};
-
- &:hover,
- &:focus {
- background: ${({ theme }) => theme.colors.buttons.border.hover};
- text-decoration: none;
- }
-
- &:active {
- background: ${({ theme }) => theme.colors.buttons.border.active};
- }
-
- &[disabled] {
- pointer-events: none;
- background: ${({ theme }) => theme.colors.buttons.bgDisabled};
- }
-`;
-
const StyledOr = styled.div`
background: ${props => props.theme.colors.levels.surface};
display: flex;
@@ -528,11 +448,13 @@ const StyledOr = styled.div`
justify-content: center;
position: absolute;
z-index: 1;
+ text-transform: uppercase;
`;
-const loginViews = { default: [Primary, Secondary] };
+const loginViews = { default: [LoginOptions, LocalLogin] };
export type Props = {
+ // Deprecated. TODO(bl-nero): Remove after e/ is updated.
title?: string;
isLocalAuthEnabled?: boolean;
isPasswordlessEnabled: boolean;
diff --git a/web/packages/teleport/src/components/FormLogin/SsoButtons.tsx b/web/packages/teleport/src/components/FormLogin/SsoButtons.tsx
index d1e070d77f4ca..ae304e787a47b 100644
--- a/web/packages/teleport/src/components/FormLogin/SsoButtons.tsx
+++ b/web/packages/teleport/src/components/FormLogin/SsoButtons.tsx
@@ -17,17 +17,18 @@
*/
import React, { forwardRef } from 'react';
+import styled from 'styled-components';
import { Box, Text } from 'design';
import ButtonSso, { guessProviderType } from 'shared/components/ButtonSso';
import { AuthProvider } from 'shared/services';
const SSOBtnList = forwardRef(
- ({ providers, prefixText, isDisabled, onClick, autoFocus = false }, ref) => {
+ ({ providers, isDisabled, onClick, autoFocus = false }, ref) => {
+ const style = providers.length === 1 ? { gridColumnEnd: 'span 2' } : {};
const $btns = providers.map((item, index) => {
let { name, type, displayName } = item;
- const title = displayName || `${prefixText} ${name}`;
+ const title = displayName || name;
const ssoType = guessProviderType(title, type);
- const len = providers.length - 1;
return (
(
title={title}
ssoType={ssoType}
disabled={isDisabled}
- mt={3}
- mb={index < len ? 3 : 0}
autoFocus={index === 0 && autoFocus}
+ style={style}
onClick={e => {
e.preventDefault();
onClick(item);
@@ -54,16 +54,11 @@ const SSOBtnList = forwardRef(
);
}
- return (
-
- {$btns}
-
- );
+ return {$btns};
}
);
type Props = {
- prefixText: string;
isDisabled: boolean;
onClick(provider: AuthProvider): void;
providers: AuthProvider[];
@@ -71,4 +66,10 @@ type Props = {
autoFocus?: boolean;
};
+const Container = styled(Box)`
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: ${p => p.theme.space[3]}px;
+`;
+
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 7e3578d95e985..e73634a4fb7cd 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,8 +7,9 @@ exports[`auth2faType: off 1`] = `
margin-right: auto;
margin-top: 32px;
margin-bottom: 32px;
+ padding-top: 24px;
padding-bottom: 24px;
- width: 464px;
+ width: 650px;
}
.c3 {
@@ -17,25 +18,28 @@ exports[`auth2faType: off 1`] = `
.c5 {
box-sizing: border-box;
- padding-top: 16px;
- padding-left: 32px;
- padding-right: 32px;
+ padding-left: 24px;
+ padding-right: 24px;
+}
+
+.c7 {
+ box-sizing: border-box;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
}
-.c7 {
+.c9 {
box-sizing: border-box;
margin-bottom: 16px;
}
-.c10 {
+.c12 {
box-sizing: border-box;
margin-bottom: 0px;
width: 100%;
}
-.c11 {
+.c13 {
line-height: 1.5;
margin: 0;
display: inline-flex;
@@ -59,21 +63,19 @@ exports[`auth2faType: off 1`] = `
min-height: 40px;
font-size: 12px;
padding: 0px 40px;
- margin-bottom: 4px;
- margin-top: 16px;
width: 100%;
}
-.c11:hover,
-.c11:focus {
+.c13:hover,
+.c13:focus {
background: #B29DFF;
}
-.c11:active {
+.c13:active {
background: #C5B6FF;
}
-.c11:disabled {
+.c13:disabled {
background: rgba(255,255,255,0.12);
color: rgba(255,255,255,0.3);
cursor: auto;
@@ -92,11 +94,11 @@ exports[`auth2faType: off 1`] = `
font-size: 22px;
line-height: 32px;
margin: 0px;
- padding-top: 24px;
+ margin-bottom: 24px;
text-align: center;
}
-.c9 {
+.c11 {
appearance: none;
border: 1px solid rgba(255,255,255,0.54);
border-radius: 4px;
@@ -112,31 +114,31 @@ exports[`auth2faType: off 1`] = `
margin-top: 4px;
}
-.c9:hover,
-.c9:focus,
-.c9:active {
+.c11:hover,
+.c11:focus,
+.c11:active {
border: 1px solid rgba(255,255,255,0.72);
}
-.c9::-ms-clear {
+.c11::-ms-clear {
display: none;
}
-.c9::placeholder {
+.c11::placeholder {
color: rgba(255,255,255,0.54);
opacity: 1;
}
-.c9:read-only {
+.c11:read-only {
cursor: not-allowed;
}
-.c9:disabled {
+.c11:disabled {
color: rgba(255,255,255,0.36);
border-color: rgba(255,255,255,0.36);
}
-.c8 {
+.c10 {
color: #FFFFFF;
display: block;
font-size: 12px;
@@ -145,6 +147,12 @@ exports[`auth2faType: off 1`] = `
}
.c6 {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.c8 {
display: flex;
justify-content: center;
flex-direction: column;
@@ -196,12 +204,12 @@ exports[`auth2faType: off 1`] = `
- Custom Title
+ Sign in to Teleport