Improve Login Selector UX (#64142)

Co-authored-by: Dave Snider <dave.snider@gmail.com>
This commit is contained in:
Aleh Zasypkin 2020-04-28 12:55:11 +02:00 committed by GitHub
parent 2fba7ed9f7
commit e7971fa08e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1010 additions and 457 deletions

View file

@ -234,6 +234,7 @@ kibana_vars=(
xpack.security.session.idleTimeout xpack.security.session.idleTimeout
xpack.security.session.lifespan xpack.security.session.lifespan
xpack.security.loginAssistanceMessage xpack.security.loginAssistanceMessage
xpack.security.loginHelp
telemetry.allowChangingOptInStatus telemetry.allowChangingOptInStatus
telemetry.enabled telemetry.enabled
telemetry.optIn telemetry.optIn

View file

@ -6,15 +6,24 @@
import { LoginLayout } from './licensing'; import { LoginLayout } from './licensing';
export interface LoginSelectorProvider {
type: string;
name: string;
usesLoginForm: boolean;
description?: string;
hint?: string;
icon?: string;
}
export interface LoginSelector { export interface LoginSelector {
enabled: boolean; enabled: boolean;
providers: Array<{ type: string; name: string; description?: string }>; providers: LoginSelectorProvider[];
} }
export interface LoginState { export interface LoginState {
layout: LoginLayout; layout: LoginLayout;
allowLogin: boolean; allowLogin: boolean;
showLoginForm: boolean;
requiresSecureConnection: boolean; requiresSecureConnection: boolean;
loginHelp?: string;
selector: LoginSelector; selector: LoginSelector;
} }

View file

@ -1,5 +0,0 @@
// Component styles
@import './components/index';
// Login styles
@import './login/index';

View file

@ -1 +0,0 @@
@import './authentication_state_page/index';

View file

@ -1 +0,0 @@
@import './authentication_state_page';

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import './_authentication_state_page.scss';
import { EuiIcon, EuiSpacer, EuiTitle } from '@elastic/eui'; import { EuiIcon, EuiSpacer, EuiTitle } from '@elastic/eui';
import React from 'react'; import React from 'react';

View file

@ -121,10 +121,15 @@ exports[`LoginPage enabled form state renders as expected 1`] = `
selector={ selector={
Object { Object {
"enabled": false, "enabled": false,
"providers": Array [], "providers": Array [
Object {
"name": "basic1",
"type": "basic",
"usesLoginForm": true,
},
],
} }
} }
showLoginForm={true}
/> />
`; `;
@ -155,10 +160,15 @@ exports[`LoginPage enabled form state renders as expected when info message is s
selector={ selector={
Object { Object {
"enabled": false, "enabled": false,
"providers": Array [], "providers": Array [
Object {
"name": "basic1",
"type": "basic",
"usesLoginForm": true,
},
],
} }
} }
showLoginForm={true}
/> />
`; `;
@ -189,10 +199,55 @@ exports[`LoginPage enabled form state renders as expected when loginAssistanceMe
selector={ selector={
Object { Object {
"enabled": false, "enabled": false,
"providers": Array [], "providers": Array [
Object {
"name": "basic1",
"type": "basic",
"usesLoginForm": true,
},
],
}
}
/>
`;
exports[`LoginPage enabled form state renders as expected when loginHelp is set 1`] = `
<LoginForm
http={
Object {
"addLoadingCountSource": [MockFunction],
"get": [MockFunction],
}
}
infoMessage="Your session has timed out. Please log in again."
loginAssistanceMessage=""
loginHelp="**some-help**"
notifications={
Object {
"toasts": Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
},
}
}
selector={
Object {
"enabled": false,
"providers": Array [
Object {
"name": "basic1",
"type": "basic",
"usesLoginForm": true,
},
],
} }
} }
showLoginForm={true}
/> />
`; `;
@ -279,10 +334,15 @@ exports[`LoginPage page renders as expected 1`] = `
selector={ selector={
Object { Object {
"enabled": false, "enabled": false,
"providers": Array [], "providers": Array [
Object {
"name": "basic1",
"type": "basic",
"usesLoginForm": true,
},
],
} }
} }
showLoginForm={true}
/> />
</EuiFlexItem> </EuiFlexItem>
</EuiFlexGroup> </EuiFlexGroup>

View file

@ -1 +0,0 @@
@import './login_page';

View file

@ -1,170 +1,91 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LoginForm login selector renders as expected with login form 1`] = ` exports[`LoginForm login selector properly switches to login form -> login help and back: Login Help 1`] = `
<Fragment> <ReactMarkdown
<EuiButton astPlugins={Array []}
fullWidth={true} escapeHtml={true}
isDisabled={false} plugins={Array []}
isLoading={false} rawSourcePos={false}
key="saml1" renderers={Object {}}
onClick={[Function]} skipHtml={false}
sourcePos={false}
transformLinkUri={[Function]}
>
<div
key="root-1-1"
> >
Login w/SAML <p
</EuiButton> key="paragraph-1-1"
<EuiSpacer
size="m"
/>
<EuiButton
fullWidth={true}
isDisabled={false}
isLoading={false}
key="pki1"
onClick={[Function]}
>
Login w/PKI
</EuiButton>
<EuiSpacer
size="m"
/>
<EuiText
color="subdued"
textAlign="center"
>
―――  
<FormattedMessage
defaultMessage="OR"
id="xpack.security.loginPage.loginSelectorOR"
values={Object {}}
/>
  ―――
</EuiText>
<EuiSpacer
size="m"
/>
<EuiPanel>
<form
onSubmit={[Function]}
> >
<EuiFormRow <strong
describedByIds={Array []} key="strong-1-1"
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label={
<FormattedMessage
defaultMessage="Username"
id="xpack.security.login.basicLoginForm.usernameFormRowLabel"
values={Object {}}
/>
}
labelType="label"
> >
<EuiFieldText some help
aria-required={true} </strong>
data-test-subj="loginUsername" </p>
disabled={false} </div>
id="username" </ReactMarkdown>
inputRef={[Function]}
isInvalid={false}
name="username"
onChange={[Function]}
value=""
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label={
<FormattedMessage
defaultMessage="Password"
id="xpack.security.login.basicLoginForm.passwordFormRowLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldPassword
aria-required={true}
autoComplete="off"
compressed={false}
data-test-subj="loginPassword"
disabled={false}
fullWidth={false}
id="password"
isInvalid={false}
isLoading={false}
name="password"
onChange={[Function]}
value=""
/>
</EuiFormRow>
<EuiButton
color="primary"
data-test-subj="loginSubmit"
fill={true}
isDisabled={false}
isLoading={false}
onClick={[Function]}
type="submit"
>
<FormattedMessage
defaultMessage="Log in"
id="xpack.security.login.basicLoginForm.logInButtonLabel"
values={Object {}}
/>
</EuiButton>
</form>
</EuiPanel>
</Fragment>
`; `;
exports[`LoginForm login selector renders as expected without login form for providers with and without description 1`] = ` exports[`LoginForm login selector properly switches to login help: Login Help 1`] = `
<Fragment> <ReactMarkdown
<EuiButton astPlugins={Array []}
fullWidth={true} escapeHtml={true}
isDisabled={false} plugins={Array []}
isLoading={false} rawSourcePos={false}
key="saml1" renderers={Object {}}
onClick={[Function]} skipHtml={false}
sourcePos={false}
transformLinkUri={[Function]}
>
<div
key="root-1-1"
> >
Login w/SAML <p
</EuiButton> key="paragraph-1-1"
<EuiSpacer >
size="m" <strong
/> key="strong-1-1"
<EuiButton >
fullWidth={true} some help
isDisabled={false} </strong>
isLoading={false} </p>
key="pki1" </div>
onClick={[Function]} </ReactMarkdown>
`;
exports[`LoginForm properly switches to login help: Login Help 1`] = `
<ReactMarkdown
astPlugins={Array []}
escapeHtml={true}
plugins={Array []}
rawSourcePos={false}
renderers={Object {}}
skipHtml={false}
sourcePos={false}
transformLinkUri={[Function]}
>
<div
key="root-1-1"
> >
<FormattedMessage <p
defaultMessage="Login with {providerType}/{providerName}" key="paragraph-1-1"
id="xpack.security.loginPage.loginProviderDescription" >
values={ <strong
Object { key="strong-1-1"
"providerName": "pki1", >
"providerType": "pki", some help
} </strong>
} </p>
/> </div>
</EuiButton> </ReactMarkdown>
<EuiSpacer
size="m"
/>
</Fragment>
`; `;
exports[`LoginForm renders as expected 1`] = ` exports[`LoginForm renders as expected 1`] = `
<Fragment> <Fragment>
<EuiPanel> <EuiPanel
data-test-subj="loginForm"
>
<form <form
onSubmit={[Function]} onSubmit={[Function]}
> >
@ -227,21 +148,32 @@ exports[`LoginForm renders as expected 1`] = `
value="" value=""
/> />
</EuiFormRow> </EuiFormRow>
<EuiButton <EuiSpacer />
color="primary" <EuiFlexGroup
data-test-subj="loginSubmit" alignItems="center"
fill={true} gutterSize="s"
isDisabled={false} responsive={false}
isLoading={false}
onClick={[Function]}
type="submit"
> >
<FormattedMessage <EuiFlexItem
defaultMessage="Log in" grow={false}
id="xpack.security.login.basicLoginForm.logInButtonLabel" >
values={Object {}} <EuiButton
/> color="primary"
</EuiButton> data-test-subj="loginSubmit"
fill={true}
isDisabled={false}
isLoading={false}
onClick={[Function]}
type="submit"
>
<FormattedMessage
defaultMessage="Log in"
id="xpack.security.login.basicLoginForm.logInButtonLabel"
values={Object {}}
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</form> </form>
</EuiPanel> </EuiPanel>
</Fragment> </Fragment>

View file

@ -0,0 +1,55 @@
.secLoginCard {
display: block;
box-shadow: none;
padding: $euiSize;
text-align: left;
width: 100%;
&:hover {
.secLoginCard__title {
text-decoration: underline;
}
}
&:disabled {
pointer-events: none;
}
&:not(.secLoginCard-isLoading):disabled {
.secLoginCard__title,
.secLoginCard__hint {
color: $euiColorMediumShade;
}
}
&:focus {
border-color: transparent;
border-radius: $euiBorderRadius;
@include euiFocusRing;
.secLoginCard__title {
text-decoration: underline;
}
// Make the focus ring clean and without borders
+ .secLoginCard {
border-color: transparent;
}
}
+ .secLoginCard {
border-top: $euiBorderThin;
}
}
.secLoginCard__hint {
@include euiFontSizeXS;
color: $euiColorDarkShade;
margin-top: $euiSizeXS;
}
.secLoginAssistanceMessage {
// This tightens up the layout if message is present
margin-top: -($euiSizeXXL + $euiSizeS);
padding: 0 $euiSize;
}

View file

@ -5,12 +5,39 @@
*/ */
import React from 'react'; import React from 'react';
import ReactMarkdown from 'react-markdown';
import { act } from '@testing-library/react'; import { act } from '@testing-library/react';
import { EuiButton, EuiCallOut } from '@elastic/eui'; import { EuiButton, EuiCallOut, EuiIcon } from '@elastic/eui';
import { mountWithIntl, nextTick, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { mountWithIntl, nextTick, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { LoginForm } from './login_form'; import { findTestSubject } from 'test_utils/find_test_subject';
import { LoginForm, PageMode } from './login_form';
import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { coreMock } from '../../../../../../../../src/core/public/mocks';
import { ReactWrapper } from 'enzyme';
function expectPageMode(wrapper: ReactWrapper, mode: PageMode) {
const assertions: Array<[string, boolean]> =
mode === PageMode.Form
? [
['loginForm', true],
['loginSelector', false],
['loginHelp', false],
]
: mode === PageMode.Selector
? [
['loginForm', false],
['loginSelector', true],
['loginHelp', false],
]
: [
['loginForm', false],
['loginSelector', false],
['loginHelp', true],
];
for (const [selector, exists] of assertions) {
expect(findTestSubject(wrapper, selector).exists()).toBe(exists);
}
}
describe('LoginForm', () => { describe('LoginForm', () => {
beforeAll(() => { beforeAll(() => {
@ -32,8 +59,10 @@ describe('LoginForm', () => {
http={coreStartMock.http} http={coreStartMock.http}
notifications={coreStartMock.notifications} notifications={coreStartMock.notifications}
loginAssistanceMessage="" loginAssistanceMessage=""
showLoginForm={true} selector={{
selector={{ enabled: false, providers: [] }} enabled: false,
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
}}
/> />
) )
).toMatchSnapshot(); ).toMatchSnapshot();
@ -41,20 +70,44 @@ describe('LoginForm', () => {
it('renders an info message when provided.', () => { it('renders an info message when provided.', () => {
const coreStartMock = coreMock.createStart(); const coreStartMock = coreMock.createStart();
const wrapper = shallowWithIntl( const wrapper = mountWithIntl(
<LoginForm <LoginForm
http={coreStartMock.http} http={coreStartMock.http}
notifications={coreStartMock.notifications} notifications={coreStartMock.notifications}
infoMessage={'Hey this is an info message'} infoMessage={'Hey this is an info message'}
loginAssistanceMessage="" loginAssistanceMessage=""
showLoginForm={true} selector={{
selector={{ enabled: false, providers: [] }} enabled: false,
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
}}
/> />
); );
expectPageMode(wrapper, PageMode.Form);
expect(wrapper.find(EuiCallOut).props().title).toEqual('Hey this is an info message'); expect(wrapper.find(EuiCallOut).props().title).toEqual('Hey this is an info message');
}); });
it('renders `Need help?` link if login help text is provided.', () => {
const coreStartMock = coreMock.createStart();
const wrapper = mountWithIntl(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginHelp={'**Hey this is a login help message**'}
loginAssistanceMessage=""
selector={{
enabled: false,
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
}}
/>
);
expectPageMode(wrapper, PageMode.Form);
expect(findTestSubject(wrapper, 'loginHelpLink').text()).toEqual('Need help?');
});
it('renders an invalid credentials message', async () => { it('renders an invalid credentials message', async () => {
const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' });
coreStartMock.http.post.mockRejectedValue({ response: { status: 401 } }); coreStartMock.http.post.mockRejectedValue({ response: { status: 401 } });
@ -64,11 +117,15 @@ describe('LoginForm', () => {
http={coreStartMock.http} http={coreStartMock.http}
notifications={coreStartMock.notifications} notifications={coreStartMock.notifications}
loginAssistanceMessage="" loginAssistanceMessage=""
showLoginForm={true} selector={{
selector={{ enabled: false, providers: [] }} enabled: false,
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
}}
/> />
); );
expectPageMode(wrapper, PageMode.Form);
wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } });
wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } });
wrapper.find(EuiButton).simulate('click'); wrapper.find(EuiButton).simulate('click');
@ -92,11 +149,15 @@ describe('LoginForm', () => {
http={coreStartMock.http} http={coreStartMock.http}
notifications={coreStartMock.notifications} notifications={coreStartMock.notifications}
loginAssistanceMessage="" loginAssistanceMessage=""
showLoginForm={true} selector={{
selector={{ enabled: false, providers: [] }} enabled: false,
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
}}
/> />
); );
expectPageMode(wrapper, PageMode.Form);
wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } });
wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } });
wrapper.find(EuiButton).simulate('click'); wrapper.find(EuiButton).simulate('click');
@ -121,11 +182,15 @@ describe('LoginForm', () => {
http={coreStartMock.http} http={coreStartMock.http}
notifications={coreStartMock.notifications} notifications={coreStartMock.notifications}
loginAssistanceMessage="" loginAssistanceMessage=""
showLoginForm={true} selector={{
selector={{ enabled: false, providers: [] }} enabled: false,
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
}}
/> />
); );
expectPageMode(wrapper, PageMode.Form);
wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username1' } }); wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username1' } });
wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password1' } }); wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password1' } });
wrapper.find(EuiButton).simulate('click'); wrapper.find(EuiButton).simulate('click');
@ -144,47 +209,125 @@ describe('LoginForm', () => {
expect(wrapper.find(EuiCallOut).exists()).toBe(false); expect(wrapper.find(EuiCallOut).exists()).toBe(false);
}); });
it('properly switches to login help', async () => {
const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' });
const wrapper = mountWithIntl(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
loginHelp="**some help**"
selector={{
enabled: false,
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
}}
/>
);
expectPageMode(wrapper, PageMode.Form);
expect(findTestSubject(wrapper, 'loginBackToSelector').exists()).toBe(false);
// Going to login help.
findTestSubject(wrapper, 'loginHelpLink').simulate('click');
wrapper.update();
expectPageMode(wrapper, PageMode.LoginHelp);
expect(findTestSubject(wrapper, 'loginHelp').find(ReactMarkdown)).toMatchSnapshot('Login Help');
// Going back to login form.
findTestSubject(wrapper, 'loginBackToLoginLink').simulate('click');
wrapper.update();
expectPageMode(wrapper, PageMode.Form);
expect(findTestSubject(wrapper, 'loginBackToSelector').exists()).toBe(false);
});
describe('login selector', () => { describe('login selector', () => {
it('renders as expected with login form', async () => { it('renders as expected with providers that use login form', async () => {
const coreStartMock = coreMock.createStart(); const coreStartMock = coreMock.createStart();
const wrapper = mountWithIntl(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
selector={{
enabled: true,
providers: [
{
type: 'basic',
name: 'basic',
usesLoginForm: true,
hint: 'Basic hint',
icon: 'logoElastic',
},
{ type: 'saml', name: 'saml1', description: 'Log in w/SAML', usesLoginForm: false },
{
type: 'pki',
name: 'pki1',
description: 'Log in w/PKI',
hint: 'PKI hint',
usesLoginForm: false,
},
],
}}
/>
);
expectPageMode(wrapper, PageMode.Selector);
expect( expect(
shallowWithIntl( wrapper.find('.secLoginCard').map(card => {
<LoginForm const hint = card.find('.secLoginCard__hint');
http={coreStartMock.http} return {
notifications={coreStartMock.notifications} title: card.find('p.secLoginCard__title').text(),
loginAssistanceMessage="" hint: hint.exists() ? hint.text() : '',
showLoginForm={true} icon: card.find(EuiIcon).props().type,
selector={{ };
enabled: true, })
providers: [ ).toEqual([
{ type: 'saml', name: 'saml1', description: 'Login w/SAML' }, { title: 'Log in with basic/basic', hint: 'Basic hint', icon: 'logoElastic' },
{ type: 'pki', name: 'pki1', description: 'Login w/PKI' }, { title: 'Log in w/SAML', hint: '', icon: 'empty' },
], { title: 'Log in w/PKI', hint: 'PKI hint', icon: 'empty' },
}} ]);
/>
)
).toMatchSnapshot();
}); });
it('renders as expected without login form for providers with and without description', async () => { it('renders as expected without providers that use login form', async () => {
const coreStartMock = coreMock.createStart(); const coreStartMock = coreMock.createStart();
const wrapper = mountWithIntl(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
selector={{
enabled: true,
providers: [
{
type: 'saml',
name: 'saml1',
description: 'Login w/SAML',
hint: 'SAML hint',
usesLoginForm: false,
},
{ type: 'pki', name: 'pki1', icon: 'some-icon', usesLoginForm: false },
],
}}
/>
);
expectPageMode(wrapper, PageMode.Selector);
expect( expect(
shallowWithIntl( wrapper.find('.secLoginCard').map(card => {
<LoginForm const hint = card.find('.secLoginCard__hint');
http={coreStartMock.http} return {
notifications={coreStartMock.notifications} title: card.find('p.secLoginCard__title').text(),
loginAssistanceMessage="" hint: hint.exists() ? hint.text() : '',
showLoginForm={false} icon: card.find(EuiIcon).props().type,
selector={{ };
enabled: true, })
providers: [ ).toEqual([
{ type: 'saml', name: 'saml1', description: 'Login w/SAML' }, { title: 'Login w/SAML', hint: 'SAML hint', icon: 'empty' },
{ type: 'pki', name: 'pki1' }, { title: 'Log in with pki/pki1', hint: '', icon: 'some-icon' },
], ]);
}}
/>
)
).toMatchSnapshot();
}); });
it('properly redirects after successful login', async () => { it('properly redirects after successful login', async () => {
@ -203,17 +346,19 @@ describe('LoginForm', () => {
http={coreStartMock.http} http={coreStartMock.http}
notifications={coreStartMock.notifications} notifications={coreStartMock.notifications}
loginAssistanceMessage="" loginAssistanceMessage=""
showLoginForm={true}
selector={{ selector={{
enabled: true, enabled: true,
providers: [ providers: [
{ type: 'saml', name: 'saml1', description: 'Login w/SAML' }, { type: 'basic', name: 'basic', usesLoginForm: true },
{ type: 'pki', name: 'pki1', description: 'Login w/PKI' }, { type: 'saml', name: 'saml1', description: 'Login w/SAML', usesLoginForm: false },
{ type: 'pki', name: 'pki1', description: 'Login w/PKI', usesLoginForm: false },
], ],
}} }}
/> />
); );
expectPageMode(wrapper, PageMode.Selector);
wrapper.findWhere(node => node.key() === 'saml1').simulate('click'); wrapper.findWhere(node => node.key() === 'saml1').simulate('click');
await act(async () => { await act(async () => {
@ -246,11 +391,18 @@ describe('LoginForm', () => {
http={coreStartMock.http} http={coreStartMock.http}
notifications={coreStartMock.notifications} notifications={coreStartMock.notifications}
loginAssistanceMessage="" loginAssistanceMessage=""
showLoginForm={true} selector={{
selector={{ enabled: true, providers: [{ type: 'saml', name: 'saml1' }] }} enabled: true,
providers: [
{ type: 'basic', name: 'basic', usesLoginForm: true },
{ type: 'saml', name: 'saml1', usesLoginForm: false },
],
}}
/> />
); );
expectPageMode(wrapper, PageMode.Selector);
wrapper.findWhere(node => node.key() === 'saml1').simulate('click'); wrapper.findWhere(node => node.key() === 'saml1').simulate('click');
await act(async () => { await act(async () => {
@ -268,5 +420,123 @@ describe('LoginForm', () => {
title: 'Could not perform login.', title: 'Could not perform login.',
}); });
}); });
it('properly switches to login form', async () => {
const currentURL = `https://some-host/login?next=${encodeURIComponent(
'/some-base-path/app/kibana#/home?_g=()'
)}`;
const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' });
window.location.href = currentURL;
const wrapper = mountWithIntl(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
selector={{
enabled: true,
providers: [
{ type: 'basic', name: 'basic', usesLoginForm: true },
{ type: 'saml', name: 'saml1', usesLoginForm: false },
],
}}
/>
);
expectPageMode(wrapper, PageMode.Selector);
wrapper.findWhere(node => node.key() === 'basic').simulate('click');
wrapper.update();
expectPageMode(wrapper, PageMode.Form);
expect(coreStartMock.http.post).not.toHaveBeenCalled();
expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled();
expect(window.location.href).toBe(currentURL);
});
it('properly switches to login help', async () => {
const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' });
const wrapper = mountWithIntl(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
loginHelp="**some help**"
selector={{
enabled: true,
providers: [
{ type: 'basic', name: 'basic', usesLoginForm: true },
{ type: 'saml', name: 'saml1', usesLoginForm: false },
],
}}
/>
);
expectPageMode(wrapper, PageMode.Selector);
findTestSubject(wrapper, 'loginHelpLink').simulate('click');
wrapper.update();
expectPageMode(wrapper, PageMode.LoginHelp);
expect(findTestSubject(wrapper, 'loginHelp').find(ReactMarkdown)).toMatchSnapshot(
'Login Help'
);
// Going back to login selector.
findTestSubject(wrapper, 'loginBackToLoginLink').simulate('click');
wrapper.update();
expectPageMode(wrapper, PageMode.Selector);
expect(coreStartMock.http.post).not.toHaveBeenCalled();
expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled();
});
it('properly switches to login form -> login help and back', async () => {
const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' });
const wrapper = mountWithIntl(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
loginHelp="**some help**"
selector={{
enabled: true,
providers: [
{ type: 'basic', name: 'basic', usesLoginForm: true },
{ type: 'saml', name: 'saml1', usesLoginForm: false },
],
}}
/>
);
expectPageMode(wrapper, PageMode.Selector);
// Going to login form.
wrapper.findWhere(node => node.key() === 'basic').simulate('click');
wrapper.update();
expectPageMode(wrapper, PageMode.Form);
// Going to login help.
findTestSubject(wrapper, 'loginHelpLink').simulate('click');
wrapper.update();
expectPageMode(wrapper, PageMode.LoginHelp);
expect(findTestSubject(wrapper, 'loginHelp').find(ReactMarkdown)).toMatchSnapshot(
'Login Help'
);
// Going back to login form.
findTestSubject(wrapper, 'loginBackToLoginLink').simulate('click');
wrapper.update();
expectPageMode(wrapper, PageMode.Form);
// Going back to login selector.
findTestSubject(wrapper, 'loginBackToSelector').simulate('click');
wrapper.update();
expectPageMode(wrapper, PageMode.Selector);
expect(coreStartMock.http.post).not.toHaveBeenCalled();
expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled();
});
}); });
}); });

View file

@ -4,10 +4,13 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import './login_form.scss';
import React, { ChangeEvent, Component, FormEvent, Fragment, MouseEvent } from 'react'; import React, { ChangeEvent, Component, FormEvent, Fragment, MouseEvent } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { import {
EuiButton, EuiButton,
EuiIcon,
EuiCallOut, EuiCallOut,
EuiFieldPassword, EuiFieldPassword,
EuiFieldText, EuiFieldText,
@ -15,21 +18,28 @@ import {
EuiPanel, EuiPanel,
EuiSpacer, EuiSpacer,
EuiText, EuiText,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiLoadingSpinner,
EuiLink,
EuiHorizontalRule,
} from '@elastic/eui'; } from '@elastic/eui';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react';
import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public'; import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public';
import { LoginValidator, LoginValidationResult } from './validate_login';
import { parseNext } from '../../../../../common/parse_next'; import { parseNext } from '../../../../../common/parse_next';
import { LoginSelector } from '../../../../../common/login_state'; import { LoginSelector } from '../../../../../common/login_state';
import { LoginValidator } from './validate_login';
interface Props { interface Props {
http: HttpStart; http: HttpStart;
notifications: NotificationsStart; notifications: NotificationsStart;
selector: LoginSelector; selector: LoginSelector;
showLoginForm: boolean;
infoMessage?: string; infoMessage?: string;
loginAssistanceMessage: string; loginAssistanceMessage: string;
loginHelp?: string;
} }
interface State { interface State {
@ -42,7 +52,8 @@ interface State {
message: message:
| { type: MessageType.None } | { type: MessageType.None }
| { type: MessageType.Danger | MessageType.Info; content: string }; | { type: MessageType.Danger | MessageType.Info; content: string };
formError: LoginValidationResult | null; mode: PageMode;
previousMode: PageMode;
} }
enum LoadingStateType { enum LoadingStateType {
@ -57,12 +68,21 @@ enum MessageType {
Danger, Danger,
} }
export enum PageMode {
Selector,
Form,
LoginHelp,
}
export class LoginForm extends Component<Props, State> { export class LoginForm extends Component<Props, State> {
private readonly validator: LoginValidator; private readonly validator: LoginValidator;
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.validator = new LoginValidator({ shouldValidate: false }); this.validator = new LoginValidator({ shouldValidate: false });
const mode = this.showLoginSelector() ? PageMode.Selector : PageMode.Form;
this.state = { this.state = {
loadingState: { type: LoadingStateType.None }, loadingState: { type: LoadingStateType.None },
username: '', username: '',
@ -70,7 +90,8 @@ export class LoginForm extends Component<Props, State> {
message: this.props.infoMessage message: this.props.infoMessage
? { type: MessageType.Info, content: this.props.infoMessage } ? { type: MessageType.Info, content: this.props.infoMessage }
: { type: MessageType.None }, : { type: MessageType.None },
formError: null, mode,
previousMode: mode,
}; };
} }
@ -79,19 +100,91 @@ export class LoginForm extends Component<Props, State> {
<Fragment> <Fragment>
{this.renderLoginAssistanceMessage()} {this.renderLoginAssistanceMessage()}
{this.renderMessage()} {this.renderMessage()}
{this.renderSelector()} {this.renderContent()}
{this.renderLoginForm()} {this.renderPageModeSwitchLink()}
</Fragment> </Fragment>
); );
} }
private renderLoginForm = () => { private renderLoginAssistanceMessage = () => {
if (!this.props.showLoginForm) { if (!this.props.loginAssistanceMessage) {
return null; return null;
} }
return ( return (
<EuiPanel> <div className="secLoginAssistanceMessage">
<EuiHorizontalRule size="half" />
<EuiText size="xs">
<ReactMarkdown>{this.props.loginAssistanceMessage}</ReactMarkdown>
</EuiText>
</div>
);
};
private renderMessage = () => {
const { message } = this.state;
if (message.type === MessageType.Danger) {
return (
<Fragment>
<EuiCallOut
size="s"
color="danger"
data-test-subj="loginErrorMessage"
title={message.content}
role="alert"
/>
<EuiSpacer size="l" />
</Fragment>
);
}
if (message.type === MessageType.Info) {
return (
<Fragment>
<EuiCallOut
size="s"
color="primary"
data-test-subj="loginInfoMessage"
title={message.content}
role="status"
/>
<EuiSpacer size="l" />
</Fragment>
);
}
return null;
};
public renderContent() {
switch (this.state.mode) {
case PageMode.Form:
return this.renderLoginForm();
case PageMode.Selector:
return this.renderSelector();
case PageMode.LoginHelp:
return this.renderLoginHelp();
}
}
private renderLoginForm = () => {
const loginSelectorLink = this.showLoginSelector() ? (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="loginBackToSelector"
size="xs"
onClick={() => this.onPageModeChange(PageMode.Selector)}
>
<FormattedMessage
id="xpack.security.loginPage.loginSelectorLinkText"
defaultMessage="See more login options"
/>
</EuiButtonEmpty>
</EuiFlexItem>
) : null;
return (
<EuiPanel data-test-subj="loginForm">
<form onSubmit={this.submitLoginForm}> <form onSubmit={this.submitLoginForm}>
<EuiFormRow <EuiFormRow
label={ label={
@ -137,67 +230,128 @@ export class LoginForm extends Component<Props, State> {
/> />
</EuiFormRow> </EuiFormRow>
<EuiButton <EuiSpacer />
fill
type="submit" <EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
color="primary" <EuiFlexItem grow={false}>
onClick={this.submitLoginForm} <EuiButton
isDisabled={!this.isLoadingState(LoadingStateType.None)} fill
isLoading={this.isLoadingState(LoadingStateType.Form)} type="submit"
data-test-subj="loginSubmit" color="primary"
> onClick={this.submitLoginForm}
<FormattedMessage isDisabled={!this.isLoadingState(LoadingStateType.None)}
id="xpack.security.login.basicLoginForm.logInButtonLabel" isLoading={this.isLoadingState(LoadingStateType.Form)}
defaultMessage="Log in" data-test-subj="loginSubmit"
/> >
</EuiButton> <FormattedMessage
id="xpack.security.login.basicLoginForm.logInButtonLabel"
defaultMessage="Log in"
/>
</EuiButton>
</EuiFlexItem>
{loginSelectorLink}
</EuiFlexGroup>
</form> </form>
</EuiPanel> </EuiPanel>
); );
}; };
private renderLoginAssistanceMessage = () => { private renderSelector = () => {
if (!this.props.loginAssistanceMessage) {
return null;
}
return ( return (
<Fragment> <EuiPanel data-test-subj="loginSelector" paddingSize="none">
<EuiText size="s"> {this.props.selector.providers.map(provider => (
<ReactMarkdown>{this.props.loginAssistanceMessage}</ReactMarkdown> <button
</EuiText> key={provider.name}
</Fragment> disabled={!this.isLoadingState(LoadingStateType.None)}
onClick={() =>
provider.usesLoginForm
? this.onPageModeChange(PageMode.Form)
: this.loginWithSelector(provider.type, provider.name)
}
className={`secLoginCard ${
this.isLoadingState(LoadingStateType.Selector, provider.name)
? 'secLoginCard-isLoading'
: ''
}`}
>
<EuiFlexGroup alignItems="center" gutterSize="m" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIcon size="xl" type={provider.icon ? provider.icon : 'empty'} />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs" className="secLoginCard__title">
<p>
{provider.description ?? (
<FormattedMessage
id="xpack.security.loginPage.loginProviderDescription"
defaultMessage="Log in with {providerType}/{providerName}"
values={{
providerType: provider.type,
providerName: provider.name,
}}
/>
)}
</p>
</EuiTitle>
{provider.hint ? <p className="secLoginCard__hint">{provider.hint}</p> : null}
</EuiFlexItem>
{this.isLoadingState(LoadingStateType.Selector, provider.name) ? (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</button>
))}
</EuiPanel>
); );
}; };
private renderMessage = () => { private renderLoginHelp = () => {
const { message } = this.state; return (
if (message.type === MessageType.Danger) { <EuiPanel data-test-subj="loginHelp">
<EuiText>
<ReactMarkdown>{this.props.loginHelp || ''}</ReactMarkdown>
</EuiText>
</EuiPanel>
);
};
private renderPageModeSwitchLink = () => {
if (this.state.mode === PageMode.LoginHelp) {
return ( return (
<Fragment> <Fragment>
<EuiCallOut <EuiSpacer />
size="s" <EuiText size="xs" className="eui-textCenter">
color="danger" <EuiLink
data-test-subj="loginErrorMessage" data-test-subj="loginBackToLoginLink"
title={message.content} onClick={() => this.onPageModeChange(this.state.previousMode)}
role="alert" >
/> <FormattedMessage
<EuiSpacer size="l" /> id="xpack.security.loginPage.goBackToLoginLink"
defaultMessage="Take me back to Login"
/>
</EuiLink>
</EuiText>
</Fragment> </Fragment>
); );
} }
if (message.type === MessageType.Info) { if (this.props.loginHelp) {
return ( return (
<Fragment> <Fragment>
<EuiCallOut <EuiSpacer />
size="s" <EuiText size="xs" className="eui-textCenter">
color="primary" <EuiLink
data-test-subj="loginInfoMessage" data-test-subj="loginHelpLink"
title={message.content} onClick={() => this.onPageModeChange(PageMode.LoginHelp)}
role="status" >
/> <FormattedMessage
<EuiSpacer size="l" /> id="xpack.security.loginPage.loginHelpLinkText"
defaultMessage="Need help?"
/>
</EuiLink>
</EuiText>
</Fragment> </Fragment>
); );
} }
@ -205,60 +359,16 @@ export class LoginForm extends Component<Props, State> {
return null; return null;
}; };
private renderSelector = () => {
const showLoginSelector =
this.props.selector.enabled && this.props.selector.providers.length > 0;
if (!showLoginSelector) {
return null;
}
const loginSelectorAndLoginFormSeparator = showLoginSelector && this.props.showLoginForm && (
<>
<EuiText textAlign="center" color="subdued">
&nbsp;&nbsp;
<FormattedMessage id="xpack.security.loginPage.loginSelectorOR" defaultMessage="OR" />
&nbsp;&nbsp;
</EuiText>
<EuiSpacer size="m" />
</>
);
return (
<>
{this.props.selector.providers.map((provider, index) => (
<Fragment key={index}>
<EuiButton
key={provider.name}
fullWidth={true}
isDisabled={!this.isLoadingState(LoadingStateType.None)}
isLoading={this.isLoadingState(LoadingStateType.Selector, provider.name)}
onClick={() => this.loginWithSelector(provider.type, provider.name)}
>
{provider.description ?? (
<FormattedMessage
id="xpack.security.loginPage.loginProviderDescription"
defaultMessage="Login with {providerType}/{providerName}"
values={{
providerType: provider.type,
providerName: provider.name,
}}
/>
)}
</EuiButton>
<EuiSpacer size="m" />
</Fragment>
))}
{loginSelectorAndLoginFormSeparator}
</>
);
};
private setUsernameInputRef(ref: HTMLInputElement) { private setUsernameInputRef(ref: HTMLInputElement) {
if (ref) { if (ref) {
ref.focus(); ref.focus();
} }
} }
private onPageModeChange = (mode: PageMode) => {
this.setState({ message: { type: MessageType.None }, mode, previousMode: this.state.mode });
};
private onUsernameChange = (e: ChangeEvent<HTMLInputElement>) => { private onUsernameChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({ this.setState({
username: e.target.value, username: e.target.value,
@ -279,12 +389,10 @@ export class LoginForm extends Component<Props, State> {
this.validator.enableValidation(); this.validator.enableValidation();
const { username, password } = this.state; const { username, password } = this.state;
const result = this.validator.validateForLogin(username, password); if (this.validator.validateForLogin(username, password).isInvalid) {
if (result.isInvalid) { // Since validation is enabled now, we should ask React to re-render form and display
this.setState({ formError: result }); // validation error messages if any.
return; return this.forceUpdate();
} else {
this.setState({ formError: null });
} }
this.setState({ this.setState({
@ -351,4 +459,11 @@ export class LoginForm extends Component<Props, State> {
loadingState.type !== LoadingStateType.Selector || loadingState.providerName === providerName loadingState.type !== LoadingStateType.Selector || loadingState.providerName === providerName
); );
} }
private showLoginSelector() {
return (
this.props.selector.enabled &&
this.props.selector.providers.some(provider => !provider.usesLoginForm)
);
}
} }

View file

@ -18,8 +18,10 @@ const createLoginState = (options?: Partial<LoginState>) => {
allowLogin: true, allowLogin: true,
layout: 'form', layout: 'form',
requiresSecureConnection: false, requiresSecureConnection: false,
showLoginForm: true, selector: {
selector: { enabled: false, providers: [] }, enabled: false,
providers: [{ type: 'basic', name: 'basic1', usesLoginForm: true }],
},
...options, ...options,
} as LoginState; } as LoginState;
}; };
@ -163,7 +165,9 @@ describe('LoginPage', () => {
it('renders as expected when login is not enabled', async () => { it('renders as expected when login is not enabled', async () => {
const coreStartMock = coreMock.createStart(); const coreStartMock = coreMock.createStart();
httpMock.get.mockResolvedValue(createLoginState({ showLoginForm: false })); httpMock.get.mockResolvedValue(
createLoginState({ selector: { enabled: false, providers: [] } })
);
const wrapper = shallow( const wrapper = shallow(
<LoginPage <LoginPage
@ -250,6 +254,28 @@ describe('LoginPage', () => {
expect(wrapper.find(LoginForm)).toMatchSnapshot(); expect(wrapper.find(LoginForm)).toMatchSnapshot();
}); });
it('renders as expected when loginHelp is set', async () => {
const coreStartMock = coreMock.createStart();
httpMock.get.mockResolvedValue(createLoginState({ loginHelp: '**some-help**' }));
const wrapper = shallow(
<LoginPage
http={httpMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
/>
);
await act(async () => {
await nextTick();
wrapper.update();
resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot
});
expect(wrapper.find(LoginForm)).toMatchSnapshot();
});
}); });
describe('API calls', () => { describe('API calls', () => {

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import './_login_page.scss';
import React, { Component } from 'react'; import React, { Component } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
@ -120,10 +122,9 @@ export class LoginPage extends Component<Props, State> {
requiresSecureConnection, requiresSecureConnection,
isSecureConnection, isSecureConnection,
selector, selector,
showLoginForm, loginHelp,
}: LoginState & { isSecureConnection: boolean }) => { }: LoginState & { isSecureConnection: boolean }) => {
const isLoginExplicitlyDisabled = const isLoginExplicitlyDisabled = selector.providers.length === 0;
!showLoginForm && (!selector.enabled || selector.providers.length === 0);
if (isLoginExplicitlyDisabled) { if (isLoginExplicitlyDisabled) {
return ( return (
<DisabledLoginForm <DisabledLoginForm
@ -223,10 +224,10 @@ export class LoginPage extends Component<Props, State> {
<LoginForm <LoginForm
http={this.props.http} http={this.props.http}
notifications={this.props.notifications} notifications={this.props.notifications}
showLoginForm={showLoginForm}
selector={selector} selector={selector}
infoMessage={infoMessageMap.get(parse(window.location.href, true).query.msg?.toString())} infoMessage={infoMessageMap.get(parse(window.location.href, true).query.msg?.toString())}
loginAssistanceMessage={this.props.loginAssistanceMessage} loginAssistanceMessage={this.props.loginAssistanceMessage}
loginHelp={loginHelp}
/> />
); );
}; };

View file

@ -1,7 +1,4 @@
$secFormWidth: 460px; $secFormWidth: 460px;
// Authentication styles
@import './authentication/index';
// Management styles // Management styles
@import './management/index'; @import './management/index';

View file

@ -29,6 +29,8 @@ describe('config schema', () => {
"basic": Object { "basic": Object {
"description": undefined, "description": undefined,
"enabled": true, "enabled": true,
"hint": undefined,
"icon": undefined,
"order": 0, "order": 0,
"showInSelector": true, "showInSelector": true,
}, },
@ -71,6 +73,8 @@ describe('config schema', () => {
"basic": Object { "basic": Object {
"description": undefined, "description": undefined,
"enabled": true, "enabled": true,
"hint": undefined,
"icon": undefined,
"order": 0, "order": 0,
"showInSelector": true, "showInSelector": true,
}, },
@ -113,6 +117,8 @@ describe('config schema', () => {
"basic": Object { "basic": Object {
"description": undefined, "description": undefined,
"enabled": true, "enabled": true,
"hint": undefined,
"icon": undefined,
"order": 0, "order": 0,
"showInSelector": true, "showInSelector": true,
}, },
@ -361,20 +367,6 @@ describe('config schema', () => {
`); `);
}); });
it('does not allow custom description', () => {
expect(() =>
ConfigSchema.validate({
authc: {
providers: { basic: { basic1: { order: 0, description: 'Some description' } } },
},
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.basic.basic1.description]: \`basic\` provider does not support custom description."
`);
});
it('cannot be hidden from selector', () => { it('cannot be hidden from selector', () => {
expect(() => expect(() =>
ConfigSchema.validate({ ConfigSchema.validate({
@ -410,7 +402,9 @@ describe('config schema', () => {
Object { Object {
"basic": Object { "basic": Object {
"basic1": Object { "basic1": Object {
"description": "Log in with Elasticsearch",
"enabled": true, "enabled": true,
"icon": "logoElastic",
"order": 0, "order": 0,
"showInSelector": true, "showInSelector": true,
}, },
@ -433,20 +427,6 @@ describe('config schema', () => {
`); `);
}); });
it('does not allow custom description', () => {
expect(() =>
ConfigSchema.validate({
authc: {
providers: { token: { token1: { order: 0, description: 'Some description' } } },
},
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.token.token1.description]: \`token\` provider does not support custom description."
`);
});
it('cannot be hidden from selector', () => { it('cannot be hidden from selector', () => {
expect(() => expect(() =>
ConfigSchema.validate({ ConfigSchema.validate({
@ -482,7 +462,9 @@ describe('config schema', () => {
Object { Object {
"token": Object { "token": Object {
"token1": Object { "token1": Object {
"description": "Log in with Elasticsearch",
"enabled": true, "enabled": true,
"icon": "logoElastic",
"order": 0, "order": 0,
"showInSelector": true, "showInSelector": true,
}, },
@ -759,12 +741,16 @@ describe('config schema', () => {
Object { Object {
"basic": Object { "basic": Object {
"basic1": Object { "basic1": Object {
"description": "Log in with Elasticsearch",
"enabled": true, "enabled": true,
"icon": "logoElastic",
"order": 0, "order": 0,
"showInSelector": true, "showInSelector": true,
}, },
"basic2": Object { "basic2": Object {
"description": "Log in with Elasticsearch",
"enabled": false, "enabled": false,
"icon": "logoElastic",
"order": 1, "order": 1,
"showInSelector": true, "showInSelector": true,
}, },
@ -1043,7 +1029,7 @@ describe('createConfig()', () => {
Object { Object {
"name": "basic1", "name": "basic1",
"options": Object { "options": Object {
"description": undefined, "description": "Log in with Elasticsearch",
"order": 3, "order": 3,
"showInSelector": true, "showInSelector": true,
}, },

View file

@ -6,6 +6,7 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { schema, Type, TypeOf } from '@kbn/config-schema'; import { schema, Type, TypeOf } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { Logger } from '../../../../src/core/server'; import { Logger } from '../../../../src/core/server';
export type ConfigType = ReturnType<typeof createConfig>; export type ConfigType = ReturnType<typeof createConfig>;
@ -21,7 +22,7 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type<any>) =
); );
type ProvidersCommonConfigType = Record< type ProvidersCommonConfigType = Record<
'enabled' | 'showInSelector' | 'order' | 'description', 'enabled' | 'showInSelector' | 'order' | 'description' | 'hint' | 'icon',
Type<any> Type<any>
>; >;
function getCommonProviderSchemaProperties(overrides: Partial<ProvidersCommonConfigType> = {}) { function getCommonProviderSchemaProperties(overrides: Partial<ProvidersCommonConfigType> = {}) {
@ -30,6 +31,8 @@ function getCommonProviderSchemaProperties(overrides: Partial<ProvidersCommonCon
showInSelector: schema.boolean({ defaultValue: true }), showInSelector: schema.boolean({ defaultValue: true }),
order: schema.number({ min: 0 }), order: schema.number({ min: 0 }),
description: schema.maybe(schema.string()), description: schema.maybe(schema.string()),
hint: schema.maybe(schema.string()),
icon: schema.maybe(schema.string()),
...overrides, ...overrides,
}; };
} }
@ -53,11 +56,12 @@ type ProvidersConfigType = TypeOf<typeof providersConfigSchema>;
const providersConfigSchema = schema.object( const providersConfigSchema = schema.object(
{ {
basic: getUniqueProviderSchema('basic', { basic: getUniqueProviderSchema('basic', {
description: schema.maybe( description: schema.string({
schema.any({ defaultValue: i18n.translate('xpack.security.loginWithElasticsearchLabel', {
validate: () => '`basic` provider does not support custom description.', defaultMessage: 'Log in with Elasticsearch',
}) }),
), }),
icon: schema.string({ defaultValue: 'logoElastic' }),
showInSelector: schema.boolean({ showInSelector: schema.boolean({
defaultValue: true, defaultValue: true,
validate: value => { validate: value => {
@ -68,11 +72,12 @@ const providersConfigSchema = schema.object(
}), }),
}), }),
token: getUniqueProviderSchema('token', { token: getUniqueProviderSchema('token', {
description: schema.maybe( description: schema.string({
schema.any({ defaultValue: i18n.translate('xpack.security.loginWithElasticsearchLabel', {
validate: () => '`token` provider does not support custom description.', defaultMessage: 'Log in with Elasticsearch',
}) }),
), }),
icon: schema.string({ defaultValue: 'logoElastic' }),
showInSelector: schema.boolean({ showInSelector: schema.boolean({
defaultValue: true, defaultValue: true,
validate: value => { validate: value => {
@ -131,6 +136,7 @@ const providersConfigSchema = schema.object(
export const ConfigSchema = schema.object({ export const ConfigSchema = schema.object({
enabled: schema.boolean({ defaultValue: true }), enabled: schema.boolean({ defaultValue: true }),
loginAssistanceMessage: schema.string({ defaultValue: '' }), loginAssistanceMessage: schema.string({ defaultValue: '' }),
loginHelp: schema.maybe(schema.string()),
cookieName: schema.string({ defaultValue: 'sid' }), cookieName: schema.string({ defaultValue: 'sid' }),
encryptionKey: schema.conditional( encryptionKey: schema.conditional(
schema.contextRef('dist'), schema.contextRef('dist'),
@ -147,7 +153,16 @@ export const ConfigSchema = schema.object({
selector: schema.object({ enabled: schema.maybe(schema.boolean()) }), selector: schema.object({ enabled: schema.maybe(schema.boolean()) }),
providers: schema.oneOf([schema.arrayOf(schema.string()), providersConfigSchema], { providers: schema.oneOf([schema.arrayOf(schema.string()), providersConfigSchema], {
defaultValue: { defaultValue: {
basic: { basic: { enabled: true, showInSelector: true, order: 0, description: undefined } }, basic: {
basic: {
enabled: true,
showInSelector: true,
order: 0,
description: undefined,
hint: undefined,
icon: undefined,
},
},
token: undefined, token: undefined,
saml: undefined, saml: undefined,
oidc: undefined, oidc: undefined,

View file

@ -15,7 +15,7 @@ import {
RouteConfig, RouteConfig,
} from '../../../../../../src/core/server'; } from '../../../../../../src/core/server';
import { SecurityLicense } from '../../../common/licensing'; import { SecurityLicense } from '../../../common/licensing';
import { LoginState } from '../../../common/login_state'; import { LoginSelectorProvider } from '../../../common/login_state';
import { ConfigType } from '../../config'; import { ConfigType } from '../../config';
import { defineLoginRoutes } from './login'; import { defineLoginRoutes } from './login';
@ -141,6 +141,10 @@ describe('Login view routes', () => {
}); });
describe('Login state route', () => { describe('Login state route', () => {
function getAuthcConfig(authcConfig: Record<string, unknown> = {}) {
return routeDefinitionParamsMock.create({ authc: { ...authcConfig } }).config.authc;
}
let routeHandler: RequestHandler<any, any, any, 'get'>; let routeHandler: RequestHandler<any, any, any, 'get'>;
let routeConfig: RouteConfig<any, any, any, 'get'>; let routeConfig: RouteConfig<any, any, any, 'get'>;
beforeEach(() => { beforeEach(() => {
@ -176,9 +180,11 @@ describe('Login view routes', () => {
const expectedPayload = { const expectedPayload = {
allowLogin: true, allowLogin: true,
layout: 'error-es-unavailable', layout: 'error-es-unavailable',
showLoginForm: true,
requiresSecureConnection: false, requiresSecureConnection: false,
selector: { enabled: false, providers: [] }, selector: {
enabled: false,
providers: [{ name: 'basic', type: 'basic', usesLoginForm: true }],
},
}; };
await expect( await expect(
routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) routeHandler({ core: contextMock } as any, request, kibanaResponseFactory)
@ -198,9 +204,11 @@ describe('Login view routes', () => {
const expectedPayload = { const expectedPayload = {
allowLogin: true, allowLogin: true,
layout: 'form', layout: 'form',
showLoginForm: true,
requiresSecureConnection: false, requiresSecureConnection: false,
selector: { enabled: false, providers: [] }, selector: {
enabled: false,
providers: [{ name: 'basic', type: 'basic', usesLoginForm: true }],
},
}; };
await expect( await expect(
routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) routeHandler({ core: contextMock } as any, request, kibanaResponseFactory)
@ -229,22 +237,46 @@ describe('Login view routes', () => {
}); });
}); });
it('returns `showLoginForm: true` only if either `basic` or `token` provider is enabled.', async () => { it('returns `useLoginForm: true` for `basic` and `token` providers.', async () => {
license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any);
const request = httpServerMock.createKibanaRequest(); const request = httpServerMock.createKibanaRequest();
const contextMock = coreMock.createRequestHandlerContext(); const contextMock = coreMock.createRequestHandlerContext();
const cases: Array<[boolean, ConfigType['authc']['sortedProviders']]> = [ const cases: Array<[LoginSelectorProvider[], ConfigType['authc']]> = [
[false, []], [[], getAuthcConfig({ providers: { basic: { basic1: { order: 0, enabled: false } } } })],
[true, [{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }]], [
[true, [{ type: 'token', name: 'token1', options: { order: 0, showInSelector: true } }]], [
{
name: 'basic1',
type: 'basic',
usesLoginForm: true,
icon: 'logoElastic',
description: 'Log in with Elasticsearch',
},
],
getAuthcConfig({ providers: { basic: { basic1: { order: 0 } } } }),
],
[
[
{
name: 'token1',
type: 'token',
usesLoginForm: true,
icon: 'logoElastic',
description: 'Log in with Elasticsearch',
},
],
getAuthcConfig({ providers: { token: { token1: { order: 0 } } } }),
],
]; ];
for (const [showLoginForm, sortedProviders] of cases) { for (const [providers, authcConfig] of cases) {
config.authc.sortedProviders = sortedProviders; config.authc = authcConfig;
const expectedPayload = expect.objectContaining({ showLoginForm }); const expectedPayload = expect.objectContaining({
selector: { enabled: false, providers },
});
await expect( await expect(
routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) routeHandler({ core: contextMock } as any, request, kibanaResponseFactory)
).resolves.toEqual({ ).resolves.toEqual({
@ -261,81 +293,142 @@ describe('Login view routes', () => {
const request = httpServerMock.createKibanaRequest(); const request = httpServerMock.createKibanaRequest();
const contextMock = coreMock.createRequestHandlerContext(); const contextMock = coreMock.createRequestHandlerContext();
const cases: Array<[ const cases: Array<[ConfigType['authc'], LoginSelectorProvider[]]> = [
boolean, // selector is disabled, multiple providers, but only basic provider should be returned.
ConfigType['authc']['sortedProviders'],
LoginState['selector']['providers']
]> = [
// selector is disabled, providers shouldn't be returned.
[ [
false, getAuthcConfig({
selector: { enabled: false },
providers: {
basic: { basic1: { order: 0 } },
saml: { saml1: { order: 1, realm: 'realm1' } },
},
}),
[ [
{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }, {
{ type: 'saml', name: 'saml1', options: { order: 1, showInSelector: true } }, name: 'basic1',
type: 'basic',
usesLoginForm: true,
icon: 'logoElastic',
description: 'Log in with Elasticsearch',
},
], ],
[],
], ],
// selector is enabled, but only basic/token is available, providers shouldn't be returned. // selector is enabled, but only basic/token is available and should be returned.
[ [
true, getAuthcConfig({
[{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }], selector: { enabled: true },
[], providers: { basic: { basic1: { order: 0 } } },
}),
[
{
name: 'basic1',
type: 'basic',
usesLoginForm: true,
icon: 'logoElastic',
description: 'Log in with Elasticsearch',
},
],
], ],
// selector is enabled, non-basic/token providers should be returned // selector is enabled, all providers should be returned
[ [
true, getAuthcConfig({
selector: { enabled: true },
providers: {
basic: {
basic1: {
order: 0,
description: 'some-desc1',
hint: 'some-hint1',
icon: 'logoElastic',
},
},
saml: {
saml1: { order: 1, description: 'some-desc2', realm: 'realm1', icon: 'some-icon2' },
saml2: { order: 2, description: 'some-desc3', hint: 'some-hint3', realm: 'realm2' },
},
},
}),
[ [
{ {
type: 'basic', type: 'basic',
name: 'basic1', name: 'basic1',
options: { order: 0, showInSelector: true, description: 'some-desc1' }, description: 'some-desc1',
hint: 'some-hint1',
icon: 'logoElastic',
usesLoginForm: true,
}, },
{ {
type: 'saml', type: 'saml',
name: 'saml1', name: 'saml1',
options: { order: 1, showInSelector: true, description: 'some-desc2' }, description: 'some-desc2',
icon: 'some-icon2',
usesLoginForm: false,
}, },
{ {
type: 'saml', type: 'saml',
name: 'saml2', name: 'saml2',
options: { order: 2, showInSelector: true, description: 'some-desc3' }, description: 'some-desc3',
hint: 'some-hint3',
usesLoginForm: false,
}, },
], ],
[
{ type: 'saml', name: 'saml1', description: 'some-desc2' },
{ type: 'saml', name: 'saml2', description: 'some-desc3' },
],
], ],
// selector is enabled, only non-basic/token providers that are enabled in selector should be returned. // selector is enabled, only providers that are enabled should be returned.
[ [
true, getAuthcConfig({
selector: { enabled: true },
providers: {
basic: {
basic1: {
order: 0,
description: 'some-desc1',
hint: 'some-hint1',
icon: 'some-icon1',
},
},
saml: {
saml1: {
order: 1,
description: 'some-desc2',
realm: 'realm1',
showInSelector: false,
},
saml2: {
order: 2,
description: 'some-desc3',
hint: 'some-hint3',
icon: 'some-icon3',
realm: 'realm2',
},
},
},
}),
[ [
{ {
type: 'basic', type: 'basic',
name: 'basic1', name: 'basic1',
options: { order: 0, showInSelector: true, description: 'some-desc1' }, description: 'some-desc1',
}, hint: 'some-hint1',
{ icon: 'some-icon1',
type: 'saml', usesLoginForm: true,
name: 'saml1',
options: { order: 1, showInSelector: false, description: 'some-desc2' },
}, },
{ {
type: 'saml', type: 'saml',
name: 'saml2', name: 'saml2',
options: { order: 2, showInSelector: true, description: 'some-desc3' }, description: 'some-desc3',
hint: 'some-hint3',
icon: 'some-icon3',
usesLoginForm: false,
}, },
], ],
[{ type: 'saml', name: 'saml2', description: 'some-desc3' }],
], ],
]; ];
for (const [selectorEnabled, sortedProviders, expectedProviders] of cases) { for (const [authcConfig, expectedProviders] of cases) {
config.authc.selector.enabled = selectorEnabled; config.authc = authcConfig;
config.authc.sortedProviders = sortedProviders;
const expectedPayload = expect.objectContaining({ const expectedPayload = expect.objectContaining({
selector: { enabled: selectorEnabled, providers: expectedProviders }, selector: { enabled: authcConfig.selector.enabled, providers: expectedProviders },
}); });
await expect( await expect(
routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) routeHandler({ core: contextMock } as any, request, kibanaResponseFactory)

View file

@ -55,15 +55,16 @@ export function defineLoginRoutes({
const { allowLogin, layout = 'form' } = license.getFeatures(); const { allowLogin, layout = 'form' } = license.getFeatures();
const { sortedProviders, selector } = config.authc; const { sortedProviders, selector } = config.authc;
let showLoginForm = false;
const providers = []; const providers = [];
for (const { type, name, options } of sortedProviders) { for (const { type, name } of sortedProviders) {
if (options.showInSelector) { // Since `config.authc.sortedProviders` is based on `config.authc.providers` config we can
if (type === 'basic' || type === 'token') { // be sure that config is present for every provider in `config.authc.sortedProviders`.
showLoginForm = true; const { showInSelector, description, hint, icon } = config.authc.providers[type]?.[name]!;
} else if (selector.enabled) {
providers.push({ type, name, description: options.description }); // Include provider into the list if either selector is enabled or provider uses login form.
} const usesLoginForm = type === 'basic' || type === 'token';
if (showInSelector && (usesLoginForm || selector.enabled)) {
providers.push({ type, name, usesLoginForm, description, hint, icon });
} }
} }
@ -71,7 +72,7 @@ export function defineLoginRoutes({
allowLogin, allowLogin,
layout, layout,
requiresSecureConnection: config.secureCookies, requiresSecureConnection: config.secureCookies,
showLoginForm, loginHelp: config.loginHelp,
selector: { enabled: selector.enabled, providers }, selector: { enabled: selector.enabled, providers },
}; };

View file

@ -12651,7 +12651,6 @@
"xpack.security.loginPage.esUnavailableTitle": "Elasticsearch クラスターに接続できません", "xpack.security.loginPage.esUnavailableTitle": "Elasticsearch クラスターに接続できません",
"xpack.security.loginPage.loginProviderDescription": "{providerType}/{providerName} でログイン", "xpack.security.loginPage.loginProviderDescription": "{providerType}/{providerName} でログイン",
"xpack.security.loginPage.loginSelectorErrorMessage": "ログインを実行できませんでした。", "xpack.security.loginPage.loginSelectorErrorMessage": "ログインを実行できませんでした。",
"xpack.security.loginPage.loginSelectorOR": "OR",
"xpack.security.loginPage.noLoginMethodsAvailableMessage": "システム管理者にお問い合わせください。", "xpack.security.loginPage.noLoginMethodsAvailableMessage": "システム管理者にお問い合わせください。",
"xpack.security.loginPage.noLoginMethodsAvailableTitle": "ログインが無効です。", "xpack.security.loginPage.noLoginMethodsAvailableTitle": "ログインが無効です。",
"xpack.security.loginPage.requiresSecureConnectionMessage": "システム管理者にお問い合わせください。", "xpack.security.loginPage.requiresSecureConnectionMessage": "システム管理者にお問い合わせください。",

View file

@ -12655,7 +12655,6 @@
"xpack.security.loginPage.esUnavailableTitle": "无法连接到 Elasticsearch 集群", "xpack.security.loginPage.esUnavailableTitle": "无法连接到 Elasticsearch 集群",
"xpack.security.loginPage.loginProviderDescription": "使用 {providerType}/{providerName} 登录", "xpack.security.loginPage.loginProviderDescription": "使用 {providerType}/{providerName} 登录",
"xpack.security.loginPage.loginSelectorErrorMessage": "无法执行登录。", "xpack.security.loginPage.loginSelectorErrorMessage": "无法执行登录。",
"xpack.security.loginPage.loginSelectorOR": "或",
"xpack.security.loginPage.noLoginMethodsAvailableMessage": "请联系您的管理员。", "xpack.security.loginPage.noLoginMethodsAvailableMessage": "请联系您的管理员。",
"xpack.security.loginPage.noLoginMethodsAvailableTitle": "登录已禁用。", "xpack.security.loginPage.noLoginMethodsAvailableTitle": "登录已禁用。",
"xpack.security.loginPage.requiresSecureConnectionMessage": "请联系您的管理员。", "xpack.security.loginPage.requiresSecureConnectionMessage": "请联系您的管理员。",