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.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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
* 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';
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
@import './login_page';
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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">
|
|
||||||
―――
|
|
||||||
<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) {
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
$secFormWidth: 460px;
|
$secFormWidth: 460px;
|
||||||
|
|
||||||
// Authentication styles
|
|
||||||
@import './authentication/index';
|
|
||||||
|
|
||||||
// Management styles
|
// Management styles
|
||||||
@import './management/index';
|
@import './management/index';
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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": "システム管理者にお問い合わせください。",
|
||||||
|
|
|
@ -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": "请联系您的管理员。",
|
||||||
|
|
Loading…
Reference in a new issue