Improve Login Selector UX (#64142)
Co-authored-by: Dave Snider <dave.snider@gmail.com>
This commit is contained in:
parent
2fba7ed9f7
commit
e7971fa08e
21 changed files with 1010 additions and 457 deletions
|
@ -234,6 +234,7 @@ kibana_vars=(
|
|||
xpack.security.session.idleTimeout
|
||||
xpack.security.session.lifespan
|
||||
xpack.security.loginAssistanceMessage
|
||||
xpack.security.loginHelp
|
||||
telemetry.allowChangingOptInStatus
|
||||
telemetry.enabled
|
||||
telemetry.optIn
|
||||
|
|
|
@ -6,15 +6,24 @@
|
|||
|
||||
import { LoginLayout } from './licensing';
|
||||
|
||||
export interface LoginSelectorProvider {
|
||||
type: string;
|
||||
name: string;
|
||||
usesLoginForm: boolean;
|
||||
description?: string;
|
||||
hint?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface LoginSelector {
|
||||
enabled: boolean;
|
||||
providers: Array<{ type: string; name: string; description?: string }>;
|
||||
providers: LoginSelectorProvider[];
|
||||
}
|
||||
|
||||
export interface LoginState {
|
||||
layout: LoginLayout;
|
||||
allowLogin: boolean;
|
||||
showLoginForm: boolean;
|
||||
requiresSecureConnection: boolean;
|
||||
loginHelp?: string;
|
||||
selector: LoginSelector;
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
// Component styles
|
||||
@import './components/index';
|
||||
|
||||
// Login styles
|
||||
@import './login/index';
|
|
@ -1 +0,0 @@
|
|||
@import './authentication_state_page/index';
|
|
@ -1 +0,0 @@
|
|||
@import './authentication_state_page';
|
|
@ -4,6 +4,8 @@
|
|||
* 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 React from 'react';
|
||||
|
||||
|
|
|
@ -121,10 +121,15 @@ exports[`LoginPage enabled form state renders as expected 1`] = `
|
|||
selector={
|
||||
Object {
|
||||
"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={
|
||||
Object {
|
||||
"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={
|
||||
Object {
|
||||
"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={
|
||||
Object {
|
||||
"enabled": false,
|
||||
"providers": Array [],
|
||||
"providers": Array [
|
||||
Object {
|
||||
"name": "basic1",
|
||||
"type": "basic",
|
||||
"usesLoginForm": true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
showLoginForm={true}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
@import './login_page';
|
|
@ -1,170 +1,91 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`LoginForm login selector renders as expected with login form 1`] = `
|
||||
<Fragment>
|
||||
<EuiButton
|
||||
fullWidth={true}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
key="saml1"
|
||||
onClick={[Function]}
|
||||
exports[`LoginForm login selector properly switches to login form -> login help and back: 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"
|
||||
>
|
||||
Login w/SAML
|
||||
</EuiButton>
|
||||
<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]}
|
||||
<p
|
||||
key="paragraph-1-1"
|
||||
>
|
||||
<EuiFormRow
|
||||
describedByIds={Array []}
|
||||
display="row"
|
||||
fullWidth={false}
|
||||
hasChildLabel={true}
|
||||
hasEmptyLabelSpace={false}
|
||||
isInvalid={false}
|
||||
label={
|
||||
<FormattedMessage
|
||||
defaultMessage="Username"
|
||||
id="xpack.security.login.basicLoginForm.usernameFormRowLabel"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
labelType="label"
|
||||
<strong
|
||||
key="strong-1-1"
|
||||
>
|
||||
<EuiFieldText
|
||||
aria-required={true}
|
||||
data-test-subj="loginUsername"
|
||||
disabled={false}
|
||||
id="username"
|
||||
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>
|
||||
some help
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
</ReactMarkdown>
|
||||
`;
|
||||
|
||||
exports[`LoginForm login selector renders as expected without login form for providers with and without description 1`] = `
|
||||
<Fragment>
|
||||
<EuiButton
|
||||
fullWidth={true}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
key="saml1"
|
||||
onClick={[Function]}
|
||||
exports[`LoginForm login selector 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"
|
||||
>
|
||||
Login w/SAML
|
||||
</EuiButton>
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
<EuiButton
|
||||
fullWidth={true}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
key="pki1"
|
||||
onClick={[Function]}
|
||||
<p
|
||||
key="paragraph-1-1"
|
||||
>
|
||||
<strong
|
||||
key="strong-1-1"
|
||||
>
|
||||
some help
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
</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
|
||||
defaultMessage="Login with {providerType}/{providerName}"
|
||||
id="xpack.security.loginPage.loginProviderDescription"
|
||||
values={
|
||||
Object {
|
||||
"providerName": "pki1",
|
||||
"providerType": "pki",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</EuiButton>
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
</Fragment>
|
||||
<p
|
||||
key="paragraph-1-1"
|
||||
>
|
||||
<strong
|
||||
key="strong-1-1"
|
||||
>
|
||||
some help
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
</ReactMarkdown>
|
||||
`;
|
||||
|
||||
exports[`LoginForm renders as expected 1`] = `
|
||||
<Fragment>
|
||||
<EuiPanel>
|
||||
<EuiPanel
|
||||
data-test-subj="loginForm"
|
||||
>
|
||||
<form
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
|
@ -227,21 +148,32 @@ exports[`LoginForm renders as expected 1`] = `
|
|||
value=""
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiButton
|
||||
color="primary"
|
||||
data-test-subj="loginSubmit"
|
||||
fill={true}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onClick={[Function]}
|
||||
type="submit"
|
||||
<EuiSpacer />
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Log in"
|
||||
id="xpack.security.login.basicLoginForm.logInButtonLabel"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiButton>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<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>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</form>
|
||||
</EuiPanel>
|
||||
</Fragment>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -5,12 +5,39 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
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 { 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 { 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', () => {
|
||||
beforeAll(() => {
|
||||
|
@ -32,8 +59,10 @@ describe('LoginForm', () => {
|
|||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
loginAssistanceMessage=""
|
||||
showLoginForm={true}
|
||||
selector={{ enabled: false, providers: [] }}
|
||||
selector={{
|
||||
enabled: false,
|
||||
providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
|
@ -41,20 +70,44 @@ describe('LoginForm', () => {
|
|||
|
||||
it('renders an info message when provided.', () => {
|
||||
const coreStartMock = coreMock.createStart();
|
||||
const wrapper = shallowWithIntl(
|
||||
const wrapper = mountWithIntl(
|
||||
<LoginForm
|
||||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
infoMessage={'Hey this is an info message'}
|
||||
loginAssistanceMessage=""
|
||||
showLoginForm={true}
|
||||
selector={{ enabled: false, providers: [] }}
|
||||
selector={{
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' });
|
||||
coreStartMock.http.post.mockRejectedValue({ response: { status: 401 } });
|
||||
|
@ -64,11 +117,15 @@ describe('LoginForm', () => {
|
|||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
loginAssistanceMessage=""
|
||||
showLoginForm={true}
|
||||
selector={{ enabled: false, providers: [] }}
|
||||
selector={{
|
||||
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="password"]').simulate('change', { target: { value: 'password' } });
|
||||
wrapper.find(EuiButton).simulate('click');
|
||||
|
@ -92,11 +149,15 @@ describe('LoginForm', () => {
|
|||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
loginAssistanceMessage=""
|
||||
showLoginForm={true}
|
||||
selector={{ enabled: false, providers: [] }}
|
||||
selector={{
|
||||
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="password"]').simulate('change', { target: { value: 'password' } });
|
||||
wrapper.find(EuiButton).simulate('click');
|
||||
|
@ -121,11 +182,15 @@ describe('LoginForm', () => {
|
|||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
loginAssistanceMessage=""
|
||||
showLoginForm={true}
|
||||
selector={{ enabled: false, providers: [] }}
|
||||
selector={{
|
||||
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="password"]').simulate('change', { target: { value: 'password1' } });
|
||||
wrapper.find(EuiButton).simulate('click');
|
||||
|
@ -144,47 +209,125 @@ describe('LoginForm', () => {
|
|||
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', () => {
|
||||
it('renders as expected with login form', async () => {
|
||||
it('renders as expected with providers that use login form', async () => {
|
||||
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(
|
||||
shallowWithIntl(
|
||||
<LoginForm
|
||||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
loginAssistanceMessage=""
|
||||
showLoginForm={true}
|
||||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'saml', name: 'saml1', description: 'Login w/SAML' },
|
||||
{ type: 'pki', name: 'pki1', description: 'Login w/PKI' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
wrapper.find('.secLoginCard').map(card => {
|
||||
const hint = card.find('.secLoginCard__hint');
|
||||
return {
|
||||
title: card.find('p.secLoginCard__title').text(),
|
||||
hint: hint.exists() ? hint.text() : '',
|
||||
icon: card.find(EuiIcon).props().type,
|
||||
};
|
||||
})
|
||||
).toEqual([
|
||||
{ title: 'Log in with basic/basic', hint: 'Basic hint', icon: 'logoElastic' },
|
||||
{ title: 'Log in w/SAML', hint: '', icon: 'empty' },
|
||||
{ title: 'Log in w/PKI', hint: 'PKI hint', icon: 'empty' },
|
||||
]);
|
||||
});
|
||||
|
||||
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 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(
|
||||
shallowWithIntl(
|
||||
<LoginForm
|
||||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
loginAssistanceMessage=""
|
||||
showLoginForm={false}
|
||||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'saml', name: 'saml1', description: 'Login w/SAML' },
|
||||
{ type: 'pki', name: 'pki1' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
wrapper.find('.secLoginCard').map(card => {
|
||||
const hint = card.find('.secLoginCard__hint');
|
||||
return {
|
||||
title: card.find('p.secLoginCard__title').text(),
|
||||
hint: hint.exists() ? hint.text() : '',
|
||||
icon: card.find(EuiIcon).props().type,
|
||||
};
|
||||
})
|
||||
).toEqual([
|
||||
{ title: 'Login w/SAML', hint: 'SAML hint', icon: 'empty' },
|
||||
{ title: 'Log in with pki/pki1', hint: '', icon: 'some-icon' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('properly redirects after successful login', async () => {
|
||||
|
@ -203,17 +346,19 @@ describe('LoginForm', () => {
|
|||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
loginAssistanceMessage=""
|
||||
showLoginForm={true}
|
||||
selector={{
|
||||
enabled: true,
|
||||
providers: [
|
||||
{ type: 'saml', name: 'saml1', description: 'Login w/SAML' },
|
||||
{ type: 'pki', name: 'pki1', description: 'Login w/PKI' },
|
||||
{ type: 'basic', name: 'basic', usesLoginForm: true },
|
||||
{ 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');
|
||||
|
||||
await act(async () => {
|
||||
|
@ -246,11 +391,18 @@ describe('LoginForm', () => {
|
|||
http={coreStartMock.http}
|
||||
notifications={coreStartMock.notifications}
|
||||
loginAssistanceMessage=""
|
||||
showLoginForm={true}
|
||||
selector={{ enabled: true, providers: [{ type: 'saml', name: 'saml1' }] }}
|
||||
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() === 'saml1').simulate('click');
|
||||
|
||||
await act(async () => {
|
||||
|
@ -268,5 +420,123 @@ describe('LoginForm', () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,10 +4,13 @@
|
|||
* 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 ReactMarkdown from 'react-markdown';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiIcon,
|
||||
EuiCallOut,
|
||||
EuiFieldPassword,
|
||||
EuiFieldText,
|
||||
|
@ -15,21 +18,28 @@ import {
|
|||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiTitle,
|
||||
EuiLoadingSpinner,
|
||||
EuiLink,
|
||||
EuiHorizontalRule,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public';
|
||||
import { LoginValidator, LoginValidationResult } from './validate_login';
|
||||
import { parseNext } from '../../../../../common/parse_next';
|
||||
import { LoginSelector } from '../../../../../common/login_state';
|
||||
import { LoginValidator } from './validate_login';
|
||||
|
||||
interface Props {
|
||||
http: HttpStart;
|
||||
notifications: NotificationsStart;
|
||||
selector: LoginSelector;
|
||||
showLoginForm: boolean;
|
||||
infoMessage?: string;
|
||||
loginAssistanceMessage: string;
|
||||
loginHelp?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
|
@ -42,7 +52,8 @@ interface State {
|
|||
message:
|
||||
| { type: MessageType.None }
|
||||
| { type: MessageType.Danger | MessageType.Info; content: string };
|
||||
formError: LoginValidationResult | null;
|
||||
mode: PageMode;
|
||||
previousMode: PageMode;
|
||||
}
|
||||
|
||||
enum LoadingStateType {
|
||||
|
@ -57,12 +68,21 @@ enum MessageType {
|
|||
Danger,
|
||||
}
|
||||
|
||||
export enum PageMode {
|
||||
Selector,
|
||||
Form,
|
||||
LoginHelp,
|
||||
}
|
||||
|
||||
export class LoginForm extends Component<Props, State> {
|
||||
private readonly validator: LoginValidator;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.validator = new LoginValidator({ shouldValidate: false });
|
||||
|
||||
const mode = this.showLoginSelector() ? PageMode.Selector : PageMode.Form;
|
||||
|
||||
this.state = {
|
||||
loadingState: { type: LoadingStateType.None },
|
||||
username: '',
|
||||
|
@ -70,7 +90,8 @@ export class LoginForm extends Component<Props, State> {
|
|||
message: this.props.infoMessage
|
||||
? { type: MessageType.Info, content: this.props.infoMessage }
|
||||
: { type: MessageType.None },
|
||||
formError: null,
|
||||
mode,
|
||||
previousMode: mode,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -79,19 +100,91 @@ export class LoginForm extends Component<Props, State> {
|
|||
<Fragment>
|
||||
{this.renderLoginAssistanceMessage()}
|
||||
{this.renderMessage()}
|
||||
{this.renderSelector()}
|
||||
{this.renderLoginForm()}
|
||||
{this.renderContent()}
|
||||
{this.renderPageModeSwitchLink()}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private renderLoginForm = () => {
|
||||
if (!this.props.showLoginForm) {
|
||||
private renderLoginAssistanceMessage = () => {
|
||||
if (!this.props.loginAssistanceMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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}>
|
||||
<EuiFormRow
|
||||
label={
|
||||
|
@ -137,67 +230,128 @@ export class LoginForm extends Component<Props, State> {
|
|||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiButton
|
||||
fill
|
||||
type="submit"
|
||||
color="primary"
|
||||
onClick={this.submitLoginForm}
|
||||
isDisabled={!this.isLoadingState(LoadingStateType.None)}
|
||||
isLoading={this.isLoadingState(LoadingStateType.Form)}
|
||||
data-test-subj="loginSubmit"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.login.basicLoginForm.logInButtonLabel"
|
||||
defaultMessage="Log in"
|
||||
/>
|
||||
</EuiButton>
|
||||
<EuiSpacer />
|
||||
|
||||
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
type="submit"
|
||||
color="primary"
|
||||
onClick={this.submitLoginForm}
|
||||
isDisabled={!this.isLoadingState(LoadingStateType.None)}
|
||||
isLoading={this.isLoadingState(LoadingStateType.Form)}
|
||||
data-test-subj="loginSubmit"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.login.basicLoginForm.logInButtonLabel"
|
||||
defaultMessage="Log in"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
{loginSelectorLink}
|
||||
</EuiFlexGroup>
|
||||
</form>
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
private renderLoginAssistanceMessage = () => {
|
||||
if (!this.props.loginAssistanceMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private renderSelector = () => {
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiText size="s">
|
||||
<ReactMarkdown>{this.props.loginAssistanceMessage}</ReactMarkdown>
|
||||
</EuiText>
|
||||
</Fragment>
|
||||
<EuiPanel data-test-subj="loginSelector" paddingSize="none">
|
||||
{this.props.selector.providers.map(provider => (
|
||||
<button
|
||||
key={provider.name}
|
||||
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 = () => {
|
||||
const { message } = this.state;
|
||||
if (message.type === MessageType.Danger) {
|
||||
private renderLoginHelp = () => {
|
||||
return (
|
||||
<EuiPanel data-test-subj="loginHelp">
|
||||
<EuiText>
|
||||
<ReactMarkdown>{this.props.loginHelp || ''}</ReactMarkdown>
|
||||
</EuiText>
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
private renderPageModeSwitchLink = () => {
|
||||
if (this.state.mode === PageMode.LoginHelp) {
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
color="danger"
|
||||
data-test-subj="loginErrorMessage"
|
||||
title={message.content}
|
||||
role="alert"
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiSpacer />
|
||||
<EuiText size="xs" className="eui-textCenter">
|
||||
<EuiLink
|
||||
data-test-subj="loginBackToLoginLink"
|
||||
onClick={() => this.onPageModeChange(this.state.previousMode)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.loginPage.goBackToLoginLink"
|
||||
defaultMessage="Take me back to Login"
|
||||
/>
|
||||
</EuiLink>
|
||||
</EuiText>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.type === MessageType.Info) {
|
||||
if (this.props.loginHelp) {
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
color="primary"
|
||||
data-test-subj="loginInfoMessage"
|
||||
title={message.content}
|
||||
role="status"
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiSpacer />
|
||||
<EuiText size="xs" className="eui-textCenter">
|
||||
<EuiLink
|
||||
data-test-subj="loginHelpLink"
|
||||
onClick={() => this.onPageModeChange(PageMode.LoginHelp)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.security.loginPage.loginHelpLinkText"
|
||||
defaultMessage="Need help?"
|
||||
/>
|
||||
</EuiLink>
|
||||
</EuiText>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
@ -205,60 +359,16 @@ export class LoginForm extends Component<Props, State> {
|
|||
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">
|
||||
―――
|
||||
<FormattedMessage id="xpack.security.loginPage.loginSelectorOR" defaultMessage="OR" />
|
||||
―――
|
||||
</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) {
|
||||
if (ref) {
|
||||
ref.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private onPageModeChange = (mode: PageMode) => {
|
||||
this.setState({ message: { type: MessageType.None }, mode, previousMode: this.state.mode });
|
||||
};
|
||||
|
||||
private onUsernameChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({
|
||||
username: e.target.value,
|
||||
|
@ -279,12 +389,10 @@ export class LoginForm extends Component<Props, State> {
|
|||
this.validator.enableValidation();
|
||||
|
||||
const { username, password } = this.state;
|
||||
const result = this.validator.validateForLogin(username, password);
|
||||
if (result.isInvalid) {
|
||||
this.setState({ formError: result });
|
||||
return;
|
||||
} else {
|
||||
this.setState({ formError: null });
|
||||
if (this.validator.validateForLogin(username, password).isInvalid) {
|
||||
// Since validation is enabled now, we should ask React to re-render form and display
|
||||
// validation error messages if any.
|
||||
return this.forceUpdate();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
|
@ -351,4 +459,11 @@ export class LoginForm extends Component<Props, State> {
|
|||
loadingState.type !== LoadingStateType.Selector || loadingState.providerName === providerName
|
||||
);
|
||||
}
|
||||
|
||||
private showLoginSelector() {
|
||||
return (
|
||||
this.props.selector.enabled &&
|
||||
this.props.selector.providers.some(provider => !provider.usesLoginForm)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,8 +18,10 @@ const createLoginState = (options?: Partial<LoginState>) => {
|
|||
allowLogin: true,
|
||||
layout: 'form',
|
||||
requiresSecureConnection: false,
|
||||
showLoginForm: true,
|
||||
selector: { enabled: false, providers: [] },
|
||||
selector: {
|
||||
enabled: false,
|
||||
providers: [{ type: 'basic', name: 'basic1', usesLoginForm: true }],
|
||||
},
|
||||
...options,
|
||||
} as LoginState;
|
||||
};
|
||||
|
@ -163,7 +165,9 @@ describe('LoginPage', () => {
|
|||
|
||||
it('renders as expected when login is not enabled', async () => {
|
||||
const coreStartMock = coreMock.createStart();
|
||||
httpMock.get.mockResolvedValue(createLoginState({ showLoginForm: false }));
|
||||
httpMock.get.mockResolvedValue(
|
||||
createLoginState({ selector: { enabled: false, providers: [] } })
|
||||
);
|
||||
|
||||
const wrapper = shallow(
|
||||
<LoginPage
|
||||
|
@ -250,6 +254,28 @@ describe('LoginPage', () => {
|
|||
|
||||
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', () => {
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import './_login_page.scss';
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
|
@ -120,10 +122,9 @@ export class LoginPage extends Component<Props, State> {
|
|||
requiresSecureConnection,
|
||||
isSecureConnection,
|
||||
selector,
|
||||
showLoginForm,
|
||||
loginHelp,
|
||||
}: LoginState & { isSecureConnection: boolean }) => {
|
||||
const isLoginExplicitlyDisabled =
|
||||
!showLoginForm && (!selector.enabled || selector.providers.length === 0);
|
||||
const isLoginExplicitlyDisabled = selector.providers.length === 0;
|
||||
if (isLoginExplicitlyDisabled) {
|
||||
return (
|
||||
<DisabledLoginForm
|
||||
|
@ -223,10 +224,10 @@ export class LoginPage extends Component<Props, State> {
|
|||
<LoginForm
|
||||
http={this.props.http}
|
||||
notifications={this.props.notifications}
|
||||
showLoginForm={showLoginForm}
|
||||
selector={selector}
|
||||
infoMessage={infoMessageMap.get(parse(window.location.href, true).query.msg?.toString())}
|
||||
loginAssistanceMessage={this.props.loginAssistanceMessage}
|
||||
loginHelp={loginHelp}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
$secFormWidth: 460px;
|
||||
|
||||
// Authentication styles
|
||||
@import './authentication/index';
|
||||
|
||||
// Management styles
|
||||
@import './management/index';
|
||||
|
|
|
@ -29,6 +29,8 @@ describe('config schema', () => {
|
|||
"basic": Object {
|
||||
"description": undefined,
|
||||
"enabled": true,
|
||||
"hint": undefined,
|
||||
"icon": undefined,
|
||||
"order": 0,
|
||||
"showInSelector": true,
|
||||
},
|
||||
|
@ -71,6 +73,8 @@ describe('config schema', () => {
|
|||
"basic": Object {
|
||||
"description": undefined,
|
||||
"enabled": true,
|
||||
"hint": undefined,
|
||||
"icon": undefined,
|
||||
"order": 0,
|
||||
"showInSelector": true,
|
||||
},
|
||||
|
@ -113,6 +117,8 @@ describe('config schema', () => {
|
|||
"basic": Object {
|
||||
"description": undefined,
|
||||
"enabled": true,
|
||||
"hint": undefined,
|
||||
"icon": undefined,
|
||||
"order": 0,
|
||||
"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', () => {
|
||||
expect(() =>
|
||||
ConfigSchema.validate({
|
||||
|
@ -410,7 +402,9 @@ describe('config schema', () => {
|
|||
Object {
|
||||
"basic": Object {
|
||||
"basic1": Object {
|
||||
"description": "Log in with Elasticsearch",
|
||||
"enabled": true,
|
||||
"icon": "logoElastic",
|
||||
"order": 0,
|
||||
"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', () => {
|
||||
expect(() =>
|
||||
ConfigSchema.validate({
|
||||
|
@ -482,7 +462,9 @@ describe('config schema', () => {
|
|||
Object {
|
||||
"token": Object {
|
||||
"token1": Object {
|
||||
"description": "Log in with Elasticsearch",
|
||||
"enabled": true,
|
||||
"icon": "logoElastic",
|
||||
"order": 0,
|
||||
"showInSelector": true,
|
||||
},
|
||||
|
@ -759,12 +741,16 @@ describe('config schema', () => {
|
|||
Object {
|
||||
"basic": Object {
|
||||
"basic1": Object {
|
||||
"description": "Log in with Elasticsearch",
|
||||
"enabled": true,
|
||||
"icon": "logoElastic",
|
||||
"order": 0,
|
||||
"showInSelector": true,
|
||||
},
|
||||
"basic2": Object {
|
||||
"description": "Log in with Elasticsearch",
|
||||
"enabled": false,
|
||||
"icon": "logoElastic",
|
||||
"order": 1,
|
||||
"showInSelector": true,
|
||||
},
|
||||
|
@ -1043,7 +1029,7 @@ describe('createConfig()', () => {
|
|||
Object {
|
||||
"name": "basic1",
|
||||
"options": Object {
|
||||
"description": undefined,
|
||||
"description": "Log in with Elasticsearch",
|
||||
"order": 3,
|
||||
"showInSelector": true,
|
||||
},
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import crypto from 'crypto';
|
||||
import { schema, Type, TypeOf } from '@kbn/config-schema';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Logger } from '../../../../src/core/server';
|
||||
|
||||
export type ConfigType = ReturnType<typeof createConfig>;
|
||||
|
@ -21,7 +22,7 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type<any>) =
|
|||
);
|
||||
|
||||
type ProvidersCommonConfigType = Record<
|
||||
'enabled' | 'showInSelector' | 'order' | 'description',
|
||||
'enabled' | 'showInSelector' | 'order' | 'description' | 'hint' | 'icon',
|
||||
Type<any>
|
||||
>;
|
||||
function getCommonProviderSchemaProperties(overrides: Partial<ProvidersCommonConfigType> = {}) {
|
||||
|
@ -30,6 +31,8 @@ function getCommonProviderSchemaProperties(overrides: Partial<ProvidersCommonCon
|
|||
showInSelector: schema.boolean({ defaultValue: true }),
|
||||
order: schema.number({ min: 0 }),
|
||||
description: schema.maybe(schema.string()),
|
||||
hint: schema.maybe(schema.string()),
|
||||
icon: schema.maybe(schema.string()),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
@ -53,11 +56,12 @@ type ProvidersConfigType = TypeOf<typeof providersConfigSchema>;
|
|||
const providersConfigSchema = schema.object(
|
||||
{
|
||||
basic: getUniqueProviderSchema('basic', {
|
||||
description: schema.maybe(
|
||||
schema.any({
|
||||
validate: () => '`basic` provider does not support custom description.',
|
||||
})
|
||||
),
|
||||
description: schema.string({
|
||||
defaultValue: i18n.translate('xpack.security.loginWithElasticsearchLabel', {
|
||||
defaultMessage: 'Log in with Elasticsearch',
|
||||
}),
|
||||
}),
|
||||
icon: schema.string({ defaultValue: 'logoElastic' }),
|
||||
showInSelector: schema.boolean({
|
||||
defaultValue: true,
|
||||
validate: value => {
|
||||
|
@ -68,11 +72,12 @@ const providersConfigSchema = schema.object(
|
|||
}),
|
||||
}),
|
||||
token: getUniqueProviderSchema('token', {
|
||||
description: schema.maybe(
|
||||
schema.any({
|
||||
validate: () => '`token` provider does not support custom description.',
|
||||
})
|
||||
),
|
||||
description: schema.string({
|
||||
defaultValue: i18n.translate('xpack.security.loginWithElasticsearchLabel', {
|
||||
defaultMessage: 'Log in with Elasticsearch',
|
||||
}),
|
||||
}),
|
||||
icon: schema.string({ defaultValue: 'logoElastic' }),
|
||||
showInSelector: schema.boolean({
|
||||
defaultValue: true,
|
||||
validate: value => {
|
||||
|
@ -131,6 +136,7 @@ const providersConfigSchema = schema.object(
|
|||
export const ConfigSchema = schema.object({
|
||||
enabled: schema.boolean({ defaultValue: true }),
|
||||
loginAssistanceMessage: schema.string({ defaultValue: '' }),
|
||||
loginHelp: schema.maybe(schema.string()),
|
||||
cookieName: schema.string({ defaultValue: 'sid' }),
|
||||
encryptionKey: schema.conditional(
|
||||
schema.contextRef('dist'),
|
||||
|
@ -147,7 +153,16 @@ export const ConfigSchema = schema.object({
|
|||
selector: schema.object({ enabled: schema.maybe(schema.boolean()) }),
|
||||
providers: schema.oneOf([schema.arrayOf(schema.string()), providersConfigSchema], {
|
||||
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,
|
||||
saml: undefined,
|
||||
oidc: undefined,
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
RouteConfig,
|
||||
} from '../../../../../../src/core/server';
|
||||
import { SecurityLicense } from '../../../common/licensing';
|
||||
import { LoginState } from '../../../common/login_state';
|
||||
import { LoginSelectorProvider } from '../../../common/login_state';
|
||||
import { ConfigType } from '../../config';
|
||||
import { defineLoginRoutes } from './login';
|
||||
|
||||
|
@ -141,6 +141,10 @@ describe('Login view routes', () => {
|
|||
});
|
||||
|
||||
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 routeConfig: RouteConfig<any, any, any, 'get'>;
|
||||
beforeEach(() => {
|
||||
|
@ -176,9 +180,11 @@ describe('Login view routes', () => {
|
|||
const expectedPayload = {
|
||||
allowLogin: true,
|
||||
layout: 'error-es-unavailable',
|
||||
showLoginForm: true,
|
||||
requiresSecureConnection: false,
|
||||
selector: { enabled: false, providers: [] },
|
||||
selector: {
|
||||
enabled: false,
|
||||
providers: [{ name: 'basic', type: 'basic', usesLoginForm: true }],
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
routeHandler({ core: contextMock } as any, request, kibanaResponseFactory)
|
||||
|
@ -198,9 +204,11 @@ describe('Login view routes', () => {
|
|||
const expectedPayload = {
|
||||
allowLogin: true,
|
||||
layout: 'form',
|
||||
showLoginForm: true,
|
||||
requiresSecureConnection: false,
|
||||
selector: { enabled: false, providers: [] },
|
||||
selector: {
|
||||
enabled: false,
|
||||
providers: [{ name: 'basic', type: 'basic', usesLoginForm: true }],
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
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);
|
||||
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
const contextMock = coreMock.createRequestHandlerContext();
|
||||
|
||||
const cases: Array<[boolean, ConfigType['authc']['sortedProviders']]> = [
|
||||
[false, []],
|
||||
[true, [{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }]],
|
||||
[true, [{ type: 'token', name: 'token1', options: { order: 0, showInSelector: true } }]],
|
||||
const cases: Array<[LoginSelectorProvider[], ConfigType['authc']]> = [
|
||||
[[], getAuthcConfig({ providers: { basic: { basic1: { order: 0, enabled: false } } } })],
|
||||
[
|
||||
[
|
||||
{
|
||||
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) {
|
||||
config.authc.sortedProviders = sortedProviders;
|
||||
for (const [providers, authcConfig] of cases) {
|
||||
config.authc = authcConfig;
|
||||
|
||||
const expectedPayload = expect.objectContaining({ showLoginForm });
|
||||
const expectedPayload = expect.objectContaining({
|
||||
selector: { enabled: false, providers },
|
||||
});
|
||||
await expect(
|
||||
routeHandler({ core: contextMock } as any, request, kibanaResponseFactory)
|
||||
).resolves.toEqual({
|
||||
|
@ -261,81 +293,142 @@ describe('Login view routes', () => {
|
|||
const request = httpServerMock.createKibanaRequest();
|
||||
const contextMock = coreMock.createRequestHandlerContext();
|
||||
|
||||
const cases: Array<[
|
||||
boolean,
|
||||
ConfigType['authc']['sortedProviders'],
|
||||
LoginState['selector']['providers']
|
||||
]> = [
|
||||
// selector is disabled, providers shouldn't be returned.
|
||||
const cases: Array<[ConfigType['authc'], LoginSelectorProvider[]]> = [
|
||||
// selector is disabled, multiple providers, but only basic provider should 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,
|
||||
[{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }],
|
||||
[],
|
||||
getAuthcConfig({
|
||||
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',
|
||||
name: 'basic1',
|
||||
options: { order: 0, showInSelector: true, description: 'some-desc1' },
|
||||
description: 'some-desc1',
|
||||
hint: 'some-hint1',
|
||||
icon: 'logoElastic',
|
||||
usesLoginForm: true,
|
||||
},
|
||||
{
|
||||
type: 'saml',
|
||||
name: 'saml1',
|
||||
options: { order: 1, showInSelector: true, description: 'some-desc2' },
|
||||
description: 'some-desc2',
|
||||
icon: 'some-icon2',
|
||||
usesLoginForm: false,
|
||||
},
|
||||
{
|
||||
type: 'saml',
|
||||
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',
|
||||
name: 'basic1',
|
||||
options: { order: 0, showInSelector: true, description: 'some-desc1' },
|
||||
},
|
||||
{
|
||||
type: 'saml',
|
||||
name: 'saml1',
|
||||
options: { order: 1, showInSelector: false, description: 'some-desc2' },
|
||||
description: 'some-desc1',
|
||||
hint: 'some-hint1',
|
||||
icon: 'some-icon1',
|
||||
usesLoginForm: true,
|
||||
},
|
||||
{
|
||||
type: 'saml',
|
||||
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) {
|
||||
config.authc.selector.enabled = selectorEnabled;
|
||||
config.authc.sortedProviders = sortedProviders;
|
||||
for (const [authcConfig, expectedProviders] of cases) {
|
||||
config.authc = authcConfig;
|
||||
|
||||
const expectedPayload = expect.objectContaining({
|
||||
selector: { enabled: selectorEnabled, providers: expectedProviders },
|
||||
selector: { enabled: authcConfig.selector.enabled, providers: expectedProviders },
|
||||
});
|
||||
await expect(
|
||||
routeHandler({ core: contextMock } as any, request, kibanaResponseFactory)
|
||||
|
|
|
@ -55,15 +55,16 @@ export function defineLoginRoutes({
|
|||
const { allowLogin, layout = 'form' } = license.getFeatures();
|
||||
const { sortedProviders, selector } = config.authc;
|
||||
|
||||
let showLoginForm = false;
|
||||
const providers = [];
|
||||
for (const { type, name, options } of sortedProviders) {
|
||||
if (options.showInSelector) {
|
||||
if (type === 'basic' || type === 'token') {
|
||||
showLoginForm = true;
|
||||
} else if (selector.enabled) {
|
||||
providers.push({ type, name, description: options.description });
|
||||
}
|
||||
for (const { type, name } of sortedProviders) {
|
||||
// Since `config.authc.sortedProviders` is based on `config.authc.providers` config we can
|
||||
// be sure that config is present for every provider in `config.authc.sortedProviders`.
|
||||
const { showInSelector, description, hint, icon } = config.authc.providers[type]?.[name]!;
|
||||
|
||||
// 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,
|
||||
layout,
|
||||
requiresSecureConnection: config.secureCookies,
|
||||
showLoginForm,
|
||||
loginHelp: config.loginHelp,
|
||||
selector: { enabled: selector.enabled, providers },
|
||||
};
|
||||
|
||||
|
|
|
@ -12651,7 +12651,6 @@
|
|||
"xpack.security.loginPage.esUnavailableTitle": "Elasticsearch クラスターに接続できません",
|
||||
"xpack.security.loginPage.loginProviderDescription": "{providerType}/{providerName} でログイン",
|
||||
"xpack.security.loginPage.loginSelectorErrorMessage": "ログインを実行できませんでした。",
|
||||
"xpack.security.loginPage.loginSelectorOR": "OR",
|
||||
"xpack.security.loginPage.noLoginMethodsAvailableMessage": "システム管理者にお問い合わせください。",
|
||||
"xpack.security.loginPage.noLoginMethodsAvailableTitle": "ログインが無効です。",
|
||||
"xpack.security.loginPage.requiresSecureConnectionMessage": "システム管理者にお問い合わせください。",
|
||||
|
|
|
@ -12655,7 +12655,6 @@
|
|||
"xpack.security.loginPage.esUnavailableTitle": "无法连接到 Elasticsearch 集群",
|
||||
"xpack.security.loginPage.loginProviderDescription": "使用 {providerType}/{providerName} 登录",
|
||||
"xpack.security.loginPage.loginSelectorErrorMessage": "无法执行登录。",
|
||||
"xpack.security.loginPage.loginSelectorOR": "或",
|
||||
"xpack.security.loginPage.noLoginMethodsAvailableMessage": "请联系您的管理员。",
|
||||
"xpack.security.loginPage.noLoginMethodsAvailableTitle": "登录已禁用。",
|
||||
"xpack.security.loginPage.requiresSecureConnectionMessage": "请联系您的管理员。",
|
||||
|
|
Loading…
Reference in a new issue