Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v15] Login form UI redesign #40272

Merged
merged 6 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/packages/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 Link from 'design/Link';
Expand All @@ -36,7 +35,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';
Expand Down Expand Up @@ -524,18 +524,7 @@ function PasskeyBlurb() {
borderRadius={3}
p={3}
>
<OverlappingChip>
<Icon.FingerprintSimple p={2} />
</OverlappingChip>
<OverlappingChip>
<Icon.UsbDrive p={2} />
</OverlappingChip>
<OverlappingChip>
<Icon.UserFocus p={2} />
</OverlappingChip>
<OverlappingChip>
<Icon.DeviceMobileCamera p={2} />
</OverlappingChip>
<PasskeyIcons />
<p>
Teleport supports passkeys, a password replacement that validates your
identity using touch, facial recognition, a device password, or a PIN.
Expand All @@ -547,12 +536,3 @@ function PasskeyBlurb() {
</Box>
);
}

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;
`;
46 changes: 46 additions & 0 deletions web/packages/teleport/src/Login/Login.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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(<Login />);

// 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');
Expand All @@ -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(<Login />);

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
Expand Down
21 changes: 4 additions & 17 deletions web/packages/teleport/src/components/FormLogin/FormLogin.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import React from 'react';
import FormLogin, { Props } from './FormLogin';

const props: Props = {
title: 'Custom Title',
attempt: {
isFailed: false,
isSuccess: false,
Expand Down Expand Up @@ -74,7 +73,6 @@ export const LocalWithOnAndPwdless = () => (
export const Cloud = () => (
<FormLogin
{...props}
title="Teleport Cloud"
auth2faType="on"
isRecoveryEnabled={true}
onRecover={() => null}
Expand All @@ -89,7 +87,7 @@ export const ServerError = () => {
'invalid credentials with looooooooooooooooooooooooooooooooong text',
};

return <FormLogin {...props} title="Welcome!" attempt={attempt} />;
return <FormLogin {...props} attempt={attempt} />;
};

export const LocalWithSso = () => {
Expand All @@ -98,7 +96,7 @@ export const LocalWithSso = () => {
{ name: 'google', type: 'oidc', url: '' } as const,
];

return <FormLogin {...props} title="Welcome!" authProviders={ssoProvider} />;
return <FormLogin {...props} authProviders={ssoProvider} />;
};

export const LocalWithSsoAndPwdless = () => {
Expand All @@ -114,7 +112,6 @@ export const LocalWithSsoAndPwdless = () => {
return (
<FormLogin
{...props}
title="Welcome!"
authProviders={ssoProvider}
isPasswordlessEnabled={true}
/>
Expand All @@ -130,15 +127,14 @@ export const LocalDisabledWithSso = () => {
return (
<FormLogin
{...props}
title="Welcome!"
authProviders={ssoProvider}
isLocalAuthEnabled={false}
/>
);
};

export const LocalDisabledNoSso = () => (
<FormLogin {...props} title="Welcome!" isLocalAuthEnabled={false} />
<FormLogin {...props} isLocalAuthEnabled={false} />
);

export const PrimarySso = () => {
Expand All @@ -160,12 +156,7 @@ export const PrimarySso = () => {
];

return (
<FormLogin
{...props}
title="Welcome!"
primaryAuthType="sso"
authProviders={ssoProvider}
/>
<FormLogin {...props} primaryAuthType="sso" authProviders={ssoProvider} />
);
};

Expand All @@ -178,7 +169,6 @@ export const PrimarySsoWithPwdless = () => {
return (
<FormLogin
{...props}
title="Welcome!"
primaryAuthType="sso"
authProviders={ssoProvider}
isPasswordlessEnabled={true}
Expand All @@ -195,7 +185,6 @@ export const PrimarySsoWithSecondFactor = () => {
return (
<FormLogin
{...props}
title="Welcome!"
primaryAuthType="sso"
auth2faType="on"
authProviders={ssoProvider}
Expand All @@ -212,7 +201,6 @@ export const PrimaryPwdless = () => {
return (
<FormLogin
{...props}
title="Welcome!"
primaryAuthType="passwordless"
auth2faType="webauthn"
authProviders={ssoProvider}
Expand All @@ -224,7 +212,6 @@ export const PrimaryPwdlessWithNoSso = () => {
return (
<FormLogin
{...props}
title="Welcome!"
primaryAuthType="passwordless"
auth2faType="optional"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', '');
});
Expand All @@ -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');
});
Expand All @@ -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',
Expand All @@ -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();
Expand Down
Loading
Loading