Larry Gregory 2018-10-22 14:18:17 -04:00 committed by GitHub
parent 5d64140953
commit 7d8ce7bc01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1481 additions and 250 deletions

View file

@ -18,24 +18,13 @@
*/
export function ShieldPageProvider({ getService }) {
const remote = getService('remote');
const config = getService('config');
const defaultFindTimeout = config.get('timeouts.find');
const testSubjects = getService('testSubjects');
class ShieldPage {
login(user, pwd) {
return remote.setFindTimeout(defaultFindTimeout)
.findById('username')
.type(user)
.then(function () {
return remote.findById('password')
.type(pwd);
})
.then(function () {
return remote.findByCssSelector('button')
.click();
});
async login(user, pwd) {
await testSubjects.setValue('loginUsername', user);
await testSubjects.setValue('loginPassword', pwd);
await testSubjects.click('loginSubmit');
}
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export type LoginLayout = 'form' | 'error-es-unavailable' | 'error-xpack-unavailable';
export interface LoginState {
layout: LoginLayout;
allowLogin: boolean;
loginMessage: string;
}

View file

@ -56,6 +56,7 @@ export const security = (kibana) => new kibana.Plugin({
uiExports: {
chromeNavControls: ['plugins/security/views/nav_control'],
managementSections: ['plugins/security/views/management'],
styleSheetPaths: `${__dirname}/public/index.scss`,
apps: [{
id: 'login',
title: 'Login',

View file

@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1169" height="880" viewBox="0 0 1169 880">
<defs>
<radialGradient id="bg_bottom_branded-b" cx="44.645%" cy="43.259%" r="93.821%" fx="44.645%" fy="43.259%" gradientTransform="matrix(.54075 .76504 -.6424 .64398 .483 -.188)">
<stop offset="0%" stop-color="#D9D9D9"/>
<stop offset="100%"/>
</radialGradient>
<polygon id="bg_bottom_branded-a" points="0 0 1048 880 0 880"/>
<linearGradient id="bg_bottom_branded-c" x1="98.924%" x2="7.157%" y1="48.924%" y2="48.924%">
<stop offset="0%" stop-color="#DFDDDD" stop-opacity=".25"/>
<stop offset="100%" stop-color="#FFF" stop-opacity=".2"/>
</linearGradient>
<linearGradient id="bg_bottom_branded-e" x1="0%" y1="47.421%" y2="47.421%">
<stop offset="0%" stop-color="#FFF" stop-opacity=".6"/>
<stop offset="100%" stop-opacity=".25"/>
</linearGradient>
<polygon id="bg_bottom_branded-d" points="560 364 1169 880 560 880"/>
<linearGradient id="bg_bottom_branded-g" x1="0%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#FFF" stop-opacity=".2"/>
<stop offset="100%" stop-opacity="0"/>
</linearGradient>
<radialGradient id="bg_bottom_branded-h" cx="0%" cy="0%" r="127.62%" fx="0%" fy="0%" gradientTransform="scale(.9322 1) rotate(42.99)">
<stop offset="0%" stop-color="#BBB" stop-opacity=".1"/>
<stop offset="100%" stop-opacity=".5"/>
</radialGradient>
<polygon id="bg_bottom_branded-f" points="-12 538 342 868 -12 868"/>
<linearGradient id="bg_bottom_branded-j" x1="0%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#FFF" stop-opacity=".4"/>
<stop offset="100%" stop-opacity="0"/>
</linearGradient>
<radialGradient id="bg_bottom_branded-k" cx="0%" cy="0%" r="148.368%" fx="0%" fy="0%" gradientTransform="matrix(.674 .674 -.73874 .61493 0 0)">
<stop offset="0%" stop-color="#FFF" stop-opacity=".101"/>
<stop offset="100%" stop-opacity=".15"/>
</radialGradient>
<path id="bg_bottom_branded-i" d="M197,880 L374,880 C365.385564,795.927984 296.005151,723.868711 197,686 L197,880 Z"/>
<radialGradient id="bg_bottom_branded-m" cx="0%" cy="0%" r="127.62%" fx="0%" fy="0%" gradientTransform="scale(1 .9322) rotate(47.01)">
<stop offset="0%" stop-color="#BBB" stop-opacity=".1"/>
<stop offset="100%" stop-opacity=".5"/>
</radialGradient>
<polygon id="bg_bottom_branded-l" points="165 703 330 880 165 880"/>
</defs>
<g fill="none" fill-rule="evenodd">
<g opacity=".1">
<use fill="#E4E4E4" xlink:href="#bg_bottom_branded-a"/>
<use fill="url(#bg_bottom_branded-b)" xlink:href="#bg_bottom_branded-a" style="mix-blend-mode:overlay"/>
</g>
<g opacity=".05">
<use fill="url(#bg_bottom_branded-c)" xlink:href="#bg_bottom_branded-d"/>
<use fill="url(#bg_bottom_branded-e)" xlink:href="#bg_bottom_branded-d" style="mix-blend-mode:overlay"/>
</g>
<g opacity=".65" transform="matrix(0 -1 -1 0 868 868)">
<use fill="#DD0A73" xlink:href="#bg_bottom_branded-f"/>
<use fill="url(#bg_bottom_branded-g)" xlink:href="#bg_bottom_branded-f" style="mix-blend-mode:overlay"/>
<use fill="url(#bg_bottom_branded-h)" xlink:href="#bg_bottom_branded-f" style="mix-blend-mode:overlay"/>
</g>
<g opacity=".65">
<use fill="#017F75" xlink:href="#bg_bottom_branded-i"/>
<use fill="url(#bg_bottom_branded-j)" xlink:href="#bg_bottom_branded-i" style="mix-blend-mode:overlay"/>
<use fill="url(#bg_bottom_branded-k)" xlink:href="#bg_bottom_branded-i" style="mix-blend-mode:overlay"/>
</g>
<g opacity=".15">
<use fill="#353535" xlink:href="#bg_bottom_branded-l"/>
<use fill="url(#bg_bottom_branded-g)" xlink:href="#bg_bottom_branded-l" style="mix-blend-mode:overlay"/>
<use fill="url(#bg_bottom_branded-m)" xlink:href="#bg_bottom_branded-l" style="mix-blend-mode:overlay"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="373" viewBox="0 0 400 373">
<defs>
<linearGradient id="bg_top_branded-a" x1="10.793%" y1="50%" y2="50%">
<stop offset="0%" stop-color="#FFF" stop-opacity=".5"/>
<stop offset="100%" stop-color="#E2E2E2" stop-opacity="0"/>
</linearGradient>
<radialGradient id="bg_top_branded-c" cx="0%" cy="0%" r="146.629%" fx="0%" fy="0%" gradientTransform="matrix(.68199 .682 -.63596 .73135 0 0)">
<stop offset="0%" stop-color="#FFF" stop-opacity=".5"/>
<stop offset="100%" stop-opacity=".2"/>
</radialGradient>
<polygon id="bg_top_branded-b" points="400 373 0 0 400 0"/>
<linearGradient id="bg_top_branded-e" x1="0%" y1="35.11%" y2="0%">
<stop offset="0%" stop-color="#FFF" stop-opacity=".6"/>
<stop offset="100%" stop-opacity="0"/>
</linearGradient>
<radialGradient id="bg_top_branded-f" cy="0%" r="146.096%" fx="50%" fy="0%" gradientTransform="matrix(-.68448 .68448 -.64265 -.72903 .842 -.342)">
<stop offset="0%" stop-color="#FFF" stop-opacity=".7"/>
<stop offset="100%"/>
</radialGradient>
<polygon id="bg_top_branded-d" points="400 169 220 0 400 0"/>
</defs>
<g fill="none" fill-rule="evenodd">
<g opacity=".1">
<use fill="url(#bg_top_branded-a)" xlink:href="#bg_top_branded-b"/>
<use fill="url(#bg_top_branded-c)" xlink:href="#bg_top_branded-b" style="mix-blend-mode:overlay"/>
</g>
<g opacity=".05">
<use fill="#F5F5F5" xlink:href="#bg_top_branded-d"/>
<use fill="url(#bg_top_branded-e)" xlink:href="#bg_top_branded-d" style="mix-blend-mode:overlay"/>
<use fill="url(#bg_top_branded-f)" xlink:href="#bg_top_branded-d" style="mix-blend-mode:darken"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,4 @@
@import 'ui/public/styles/styling_constants';
// Login styles
@import './views/login/index';

View file

@ -6,16 +6,23 @@
import { parse } from 'url';
export function parseNext(href, basePath = '') {
export function parseNext(href: string, basePath = '') {
const { query, hash } = parse(href, true);
if (!query.next) {
return `${basePath}/`;
}
let next: string;
if (Array.isArray(query.next) && query.next.length > 0) {
next = query.next[0];
} else {
next = query.next as string;
}
// validate that `next` is not attempting a redirect to somewhere
// outside of this Kibana install
const { protocol, hostname, port, pathname } = parse(
query.next,
next,
false /* parseQueryString */,
true /* slashesDenoteHost */
);

View file

@ -0,0 +1,9 @@
// Prefix all styles with "login" to avoid conflicts.
// Examples
// loginChart
// loginChart__legend
// loginChart__legend--small
// loginChart__legend-isLoading
@import 'login';

View file

@ -0,0 +1,74 @@
.loginWelcome {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: $euiZLevel9 + 1000;
background: inherit;
background-image: linear-gradient(0deg, $euiColorLightestShade 0%, $euiColorEmptyShade 100%);
opacity: 0;
overflow: auto;
animation: loginWelcome_FadeIn $euiAnimSpeedExtraSlow $euiAnimSlightResistance 0s forwards;
}
.loginWelcome::before {
// SASSTODO: webpack pipeline isn't setup to handle image urls in SASS yet
// content: url(../../assets/bg_top_branded.svg);
position: absolute;
top: 0;
right: 0;
z-index: 1;
}
.loginWelcome::after {
// SASSTODO: webpack pipeline isn't setup to handle image urls in SASS yet
// content: url(../../assets/bg_bottom_branded.svg);
position: fixed;
bottom: -2px; // Hides an odd space at the bottom of the svg
left: 0;
z-index: 1;
}
.loginWelcome__header {
position: relative;
padding: $euiSizeXL;
z-index: 10;
}
.loginWelcome__logo {
margin-bottom: $euiSizeXL;
@include kibanaCircleLogo;
@include euiBottomShadowMedium;
}
.loginWelcome__footerAction {
margin-right: $euiSizeS;
}
.loginWelcome__content {
position: relative;
margin: auto;
max-width: 460px;
padding-left: $euiSizeXL;
padding-right: $euiSizeXL;
z-index: 10;
&.loginWelcome__contentDisabledForm {
max-width: 700px;
}
}
@keyframes loginWelcome_FadeIn {
from {
opacity: 0;
transform: translateY(200px), scale(0.75);
}
to {
opacity: 1;
transform: translateY(0), scale(1);
}
}

View file

@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BasicLoginForm renders as expected 1`] = `
<React.Fragment>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="m"
>
<form
onSubmit={[Function]}
>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
label="Username"
>
<EuiFieldText
aria-required={true}
compressed={false}
data-test-subj="loginUsername"
disabled={false}
fullWidth={false}
id="username"
inputRef={[Function]}
isInvalid={false}
isLoading={false}
name="username"
onChange={[Function]}
value=""
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
label="Password"
>
<EuiFieldText
aria-required={true}
compressed={false}
data-test-subj="loginPassword"
disabled={false}
fullWidth={false}
id="password"
isInvalid={false}
isLoading={false}
name="password"
onChange={[Function]}
type="password"
value=""
/>
</EuiFormRow>
<EuiButton
color="primary"
data-test-subj="loginSubmit"
fill={true}
iconSide="left"
isDisabled={true}
isLoading={false}
onClick={[Function]}
type="submit"
>
Log in
</EuiButton>
</form>
</EuiPanel>
</React.Fragment>
`;

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButton, EuiCallOut } from '@elastic/eui';
import { mount, shallow } from 'enzyme';
import React from 'react';
import { LoginState } from '../../../../../common/login_state';
import { BasicLoginForm } from './basic_login_form';
const createMockHttp = ({ simulateError = false } = {}) => {
return {
post: jest.fn(async () => {
if (simulateError) {
throw {
data: {
statusCode: 401,
},
};
}
return {
statusCode: 200,
};
}),
};
};
const createLoginState = (options?: Partial<LoginState>) => {
return {
allowLogin: true,
layout: 'form',
loginMessage: '',
...options,
} as LoginState;
};
describe('BasicLoginForm', () => {
it('renders as expected', () => {
const mockHttp = createMockHttp();
const mockWindow = {};
const loginState = createLoginState();
expect(
shallow(
<BasicLoginForm http={mockHttp} window={mockWindow} loginState={loginState} next={''} />
)
).toMatchSnapshot();
});
it('renders an info message when provided', () => {
const mockHttp = createMockHttp();
const mockWindow = {};
const loginState = createLoginState();
const wrapper = shallow(
<BasicLoginForm
http={mockHttp}
window={mockWindow}
loginState={loginState}
next={''}
infoMessage={'Hey this is an info message'}
/>
);
expect(wrapper.find(EuiCallOut).props().title).toEqual('Hey this is an info message');
});
it('renders an invalid credentials message', async () => {
const mockHttp = createMockHttp({ simulateError: true });
const mockWindow = {};
const loginState = createLoginState();
const wrapper = mount(
<BasicLoginForm http={mockHttp} window={mockWindow} loginState={loginState} next={''} />
);
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');
// Wait for ajax + rerender
await Promise.resolve();
wrapper.update();
await Promise.resolve();
wrapper.update();
expect(wrapper.find(EuiCallOut).props().title).toEqual(
`Invalid username or password. Please try again.`
);
});
});

View file

@ -0,0 +1,176 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButton, EuiCallOut, EuiFieldText, EuiFormRow, EuiPanel, EuiSpacer } from '@elastic/eui';
import React, { ChangeEvent, Component, Fragment } from 'react';
import { LoginState } from '../../../../../common/login_state';
interface Props {
http: any;
window: any;
infoMessage?: string;
loginState: LoginState;
next: string;
}
interface State {
hasError: boolean;
isLoading: boolean;
username: string;
password: string;
message: string;
}
export class BasicLoginForm extends Component<Props, State> {
public state = {
hasError: false,
isLoading: false,
username: '',
password: '',
message: '',
};
public render() {
return (
<Fragment>
{this.renderMessage()}
<EuiPanel>
<form onSubmit={this.submit}>
<EuiFormRow label="Username">
<EuiFieldText
id="username"
name="username"
data-test-subj="loginUsername"
value={this.state.username}
onChange={this.onUsernameChange}
disabled={this.state.isLoading}
isInvalid={false}
aria-required
inputRef={this.setUsernameInputRef}
/>
</EuiFormRow>
<EuiFormRow label="Password">
<EuiFieldText
id="password"
name="password"
data-test-subj="loginPassword"
type="password"
value={this.state.password}
onChange={this.onPasswordChange}
disabled={this.state.isLoading}
isInvalid={false}
aria-required
/>
</EuiFormRow>
<EuiButton
fill
type="submit"
color="primary"
onClick={this.submit}
isLoading={this.state.isLoading}
isDisabled={!this.isFormValid()}
data-test-subj="loginSubmit"
>
Log in
</EuiButton>
</form>
</EuiPanel>
</Fragment>
);
}
private renderMessage = () => {
if (this.state.message) {
return (
<Fragment>
<EuiCallOut
size="s"
color="danger"
data-test-subj="loginErrorMessage"
title={this.state.message}
role="alert"
/>
<EuiSpacer size="l" />
</Fragment>
);
}
if (this.props.infoMessage) {
return (
<Fragment>
<EuiCallOut
size="s"
color="primary"
data-test-subj="loginInfoMessage"
title={this.props.infoMessage}
role="status"
/>
<EuiSpacer size="l" />
</Fragment>
);
}
return null;
};
private setUsernameInputRef(ref: HTMLInputElement) {
if (ref) {
ref.focus();
}
}
private isFormValid = () => {
const { username, password } = this.state;
return username && password;
};
private onUsernameChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
username: e.target.value,
});
};
private onPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
password: e.target.value,
});
};
private submit = () => {
if (!this.isFormValid()) {
return;
}
this.setState({
isLoading: true,
message: '',
});
const { http, window, next } = this.props;
const { username, password } = this.state;
http.post('./api/security/v1/login', { username, password }).then(
() => (window.location.href = next),
(error: any) => {
const { statusCode = 500 } = error.data || {};
let message = 'Oops! Error. Try again.';
if (statusCode === 401) {
message = 'Invalid username or password. Please try again.';
}
this.setState({
hasError: true,
message,
isLoading: false,
});
}
);
};
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { BasicLoginForm } from './basic_login_form';

View file

@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DisabledLoginForm renders as expected 1`] = `
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="m"
>
<EuiText
color="danger"
grow={true}
style={
Object {
"textAlign": "center",
}
}
>
<p>
disabled message title
</p>
</EuiText>
<EuiText
grow={true}
style={
Object {
"textAlign": "center",
}
}
>
<p>
disabled message
</p>
</EuiText>
</EuiPanel>
`;

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { DisabledLoginForm } from './disabled_login_form';
describe('DisabledLoginForm', () => {
it('renders as expected', () => {
expect(
shallow(<DisabledLoginForm title={'disabled message title'} message={'disabled message'} />)
).toMatchSnapshot();
});
});

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiPanel, EuiText } from '@elastic/eui';
import React, { Component, ReactNode } from 'react';
interface Props {
title: ReactNode;
message: ReactNode;
}
export class DisabledLoginForm extends Component<Props, {}> {
public render() {
return (
<EuiPanel>
<EuiText color="danger" style={{ textAlign: 'center' }}>
<p>{this.props.title}</p>
</EuiText>
<EuiText style={{ textAlign: 'center' }}>
<p>{this.props.message}</p>
</EuiText>
</EuiPanel>
);
}
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { DisabledLoginForm } from './disabled_login_form';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { LoginPage } from './login_page';

View file

@ -0,0 +1,463 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LoginPage disabled form states renders as expected when a connection to ES is not available 1`] = `
<I18nProvider>
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter loginWelcome__contentDisabledForm"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="loginWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Kibana"
id="kbn.login.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiText
className="loginWelcome__subtitle"
color="subdued"
grow={true}
size="s"
>
<p>
<FormattedMessage
defaultMessage="Your window into the Elastic Stack"
id="kbn.login.welcomeDescription"
values={Object {}}
/>
</p>
</EuiText>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body loginWelcome__contentDisabledForm"
>
<EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="See the Kibana logs for details and try reloading the page."
id="kbn.login.esUnavailableMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="Cannot connect to the Elastiscearch cluster"
id="kbn.login.esUnavailableTitle"
values={Object {}}
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
</I18nProvider>
`;
exports[`LoginPage disabled form states renders as expected when an unknown loginState layout is provided 1`] = `
<I18nProvider>
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter loginWelcome__contentDisabledForm"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="loginWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Kibana"
id="kbn.login.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiText
className="loginWelcome__subtitle"
color="subdued"
grow={true}
size="s"
>
<p>
<FormattedMessage
defaultMessage="Your window into the Elastic Stack"
id="kbn.login.welcomeDescription"
values={Object {}}
/>
</p>
</EuiText>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body loginWelcome__contentDisabledForm"
>
<EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="Refer to the Kibana logs for more details and refresh to try again."
id="kbn.login.unknownLayoutMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="Unsupported login form layout."
id="kbn.login.unknownLayoutTitle"
values={Object {}}
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
</I18nProvider>
`;
exports[`LoginPage disabled form states renders as expected when secure cookies are required but not present 1`] = `
<I18nProvider>
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter loginWelcome__contentDisabledForm"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="loginWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Kibana"
id="kbn.login.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiText
className="loginWelcome__subtitle"
color="subdued"
grow={true}
size="s"
>
<p>
<FormattedMessage
defaultMessage="Your window into the Elastic Stack"
id="kbn.login.welcomeDescription"
values={Object {}}
/>
</p>
</EuiText>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body loginWelcome__contentDisabledForm"
>
<EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="Contact your system administrator."
id="kbn.login.requiresSecureConnectionMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="A secure connection is required for log in"
id="kbn.login.requiresSecureConnectionTitle"
values={Object {}}
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
</I18nProvider>
`;
exports[`LoginPage disabled form states renders as expected when xpack is not available 1`] = `
<I18nProvider>
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter loginWelcome__contentDisabledForm"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="loginWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Kibana"
id="kbn.login.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiText
className="loginWelcome__subtitle"
color="subdued"
grow={true}
size="s"
>
<p>
<FormattedMessage
defaultMessage="Your window into the Elastic Stack"
id="kbn.login.welcomeDescription"
values={Object {}}
/>
</p>
</EuiText>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body loginWelcome__contentDisabledForm"
>
<EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="To use the full set of free features in this distribution of Kibana, please update Elasticsearch to the default distribution."
id="kbn.login.xpackUnavailableMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="Cannot connect to the Elasticsearch cluster currently configured for Kibana."
id="kbn.login.xpackUnavailableTitle"
values={Object {}}
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
</I18nProvider>
`;
exports[`LoginPage enabled form state renders as expected 1`] = `
<I18nProvider>
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="loginWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Kibana"
id="kbn.login.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiText
className="loginWelcome__subtitle"
color="subdued"
grow={true}
size="s"
>
<p>
<FormattedMessage
defaultMessage="Your window into the Elastic Stack"
id="kbn.login.welcomeDescription"
values={Object {}}
/>
</p>
</EuiText>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body"
>
<EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<BasicLoginForm
http={
Object {
"post": [MockFunction],
}
}
isSecureConnection={false}
loginState={
Object {
"allowLogin": true,
"layout": "form",
"loginMessage": "",
}
}
next=""
requiresSecureConnection={false}
window={Object {}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
</I18nProvider>
`;

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { LoginPage } from './login_page';

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { LoginLayout, LoginState } from '../../../../../common/login_state';
import { LoginPage } from './login_page';
const createMockHttp = ({ simulateError = false } = {}) => {
return {
post: jest.fn(async () => {
if (simulateError) {
throw {
data: {
statusCode: 401,
},
};
}
return {
statusCode: 200,
};
}),
};
};
const createLoginState = (options?: Partial<LoginState>) => {
return {
allowLogin: true,
layout: 'form',
loginMessage: '',
...options,
} as LoginState;
};
describe('LoginPage', () => {
describe('disabled form states', () => {
it('renders as expected when secure cookies are required but not present', () => {
const props = {
http: createMockHttp(),
window: {},
next: '',
loginState: createLoginState(),
isSecureConnection: false,
requiresSecureConnection: true,
};
expect(shallow(<LoginPage {...props} />)).toMatchSnapshot();
});
it('renders as expected when a connection to ES is not available', () => {
const props = {
http: createMockHttp(),
window: {},
next: '',
loginState: createLoginState({
layout: 'error-es-unavailable',
}),
isSecureConnection: false,
requiresSecureConnection: false,
};
expect(shallow(<LoginPage {...props} />)).toMatchSnapshot();
});
it('renders as expected when xpack is not available', () => {
const props = {
http: createMockHttp(),
window: {},
next: '',
loginState: createLoginState({
layout: 'error-xpack-unavailable',
}),
isSecureConnection: false,
requiresSecureConnection: false,
};
expect(shallow(<LoginPage {...props} />)).toMatchSnapshot();
});
it('renders as expected when an unknown loginState layout is provided', () => {
const props = {
http: createMockHttp(),
window: {},
next: '',
loginState: createLoginState({
layout: 'error-asdf-asdf-unknown' as LoginLayout,
}),
isSecureConnection: false,
requiresSecureConnection: false,
};
expect(shallow(<LoginPage {...props} />)).toMatchSnapshot();
});
});
describe('enabled form state', () => {
it('renders as expected', () => {
const props = {
http: createMockHttp(),
window: {},
next: '',
loginState: createLoginState(),
isSecureConnection: false,
requiresSecureConnection: false,
};
expect(shallow(<LoginPage {...props} />)).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,172 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
// @ts-ignore
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import {
// @ts-ignore
EuiCard,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import classNames from 'classnames';
import { LoginState } from '../../../../../common/login_state';
import { BasicLoginForm } from '../basic_login_form';
import { DisabledLoginForm } from '../disabled_login_form';
interface Props {
http: any;
window: any;
next: string;
infoMessage?: string;
loginState: LoginState;
isSecureConnection: boolean;
requiresSecureConnection: boolean;
}
export class LoginPage extends Component<Props, {}> {
public render() {
const allowLogin = this.allowLogin();
const contentHeaderClasses = classNames('loginWelcome__content', 'eui-textCenter', {
['loginWelcome__contentDisabledForm']: !allowLogin,
});
const contentBodyClasses = classNames('loginWelcome__content', 'loginWelcome-body', {
['loginWelcome__contentDisabledForm']: !allowLogin,
});
return (
<I18nProvider>
<div className="loginWelcome login-form">
<header className="loginWelcome__header">
<div className={contentHeaderClasses}>
<EuiSpacer size="xxl" />
<span className="loginWelcome__logo">
<EuiIcon type="logoKibana" size="xxl" />
</span>
<EuiTitle size="l" className="loginWelcome__title">
<h1>
<FormattedMessage
id="kbn.login.welcomeTitle"
defaultMessage="Welcome to Kibana"
/>
</h1>
</EuiTitle>
<EuiText size="s" color="subdued" className="loginWelcome__subtitle">
<p>
<FormattedMessage
id="kbn.login.welcomeDescription"
defaultMessage="Your window into the Elastic Stack"
/>
</p>
</EuiText>
<EuiSpacer size="xl" />
</div>
</header>
<div className={contentBodyClasses}>
<EuiFlexGroup gutterSize="l">
<EuiFlexItem>{this.getLoginForm()}</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
</I18nProvider>
);
}
private allowLogin = () => {
if (this.props.requiresSecureConnection && !this.props.isSecureConnection) {
return false;
}
return this.props.loginState.allowLogin && this.props.loginState.layout === 'form';
};
private getLoginForm = () => {
if (this.props.requiresSecureConnection && !this.props.isSecureConnection) {
return (
<DisabledLoginForm
title={
<FormattedMessage
id="kbn.login.requiresSecureConnectionTitle"
defaultMessage="A secure connection is required for log in"
/>
}
message={
<FormattedMessage
id="kbn.login.requiresSecureConnectionMessage"
defaultMessage="Contact your system administrator."
/>
}
/>
);
}
const layout = this.props.loginState.layout;
switch (layout) {
case 'form':
return <BasicLoginForm {...this.props} />;
case 'error-es-unavailable':
return (
<DisabledLoginForm
title={
<FormattedMessage
id="kbn.login.esUnavailableTitle"
defaultMessage="Cannot connect to the Elastiscearch cluster"
/>
}
message={
<FormattedMessage
id="kbn.login.esUnavailableMessage"
defaultMessage="See the Kibana logs for details and try reloading the page."
/>
}
/>
);
case 'error-xpack-unavailable':
return (
<DisabledLoginForm
title={
<FormattedMessage
id="kbn.login.xpackUnavailableTitle"
defaultMessage="Cannot connect to the Elasticsearch cluster currently configured for Kibana."
/>
}
message={
<FormattedMessage
id="kbn.login.xpackUnavailableMessage"
defaultMessage="To use the full set of free features in this distribution of Kibana, please update Elasticsearch to the default distribution."
/>
}
/>
);
default:
return (
<DisabledLoginForm
title={
<FormattedMessage
id="kbn.login.unknownLayoutTitle"
defaultMessage="Unsupported login form layout."
/>
}
message={
<FormattedMessage
id="kbn.login.unknownLayoutMessage"
defaultMessage="Refer to the Kibana logs for more details and refresh to try again."
/>
}
/>
);
}
};
}

View file

@ -1,58 +1 @@
<div class="container" ng-class="{error: !!login.error}">
<div class="logo-container">
<div class="kibanaWelcomeLogo"></div>
</div>
<div class="form-container" ng-if="login.layout === 'form'">
<form class="login-form" ng-submit="login.submit(username, password)">
<div ng-show="login.error" class="form-group error-message">
<label class="control-label" data-test-subj="loginErrorMessage" >Oops! Error. Try again.</label>
</div>
<div ng-if="login.infoMessage" class="form-group error-message">
<label class="control-label">{{login.infoMessage}}</label>
</div>
<div ng-if="!login.allowLogin" class="form-group error-message">
<label class="control-label">{{login.loginMessage}}</label>
</div>
<div ng-if="login.isDisabled" class="form-group error-message">
<label class="control-label">Logging in requires a secure connection. Please contact your administrator.</label>
</div>
<div class="form-group inner-addon left-addon">
<i class="fa fa-user fa-lg fa-fw"></i>
<input type="text" ng-disabled="login.isDisabled || !login.allowLogin" ng-model="username" class="form-control" id="username" placeholder="Username" autofocus data-test-subj="loginUsername" />
</div>
<div class="form-group inner-addon left-addon">
<i class="fa fa-lock fa-lg fa-fw"></i>
<input type="password" ng-disabled="login.isDisabled|| !login.allowLogin" ng-model="password" class="form-control" id="password" placeholder="Password" data-test-subj="loginPassword"/>
</div>
<div class="form-group">
<button
type="submit"
ng-disabled="login.isDisabled || !login.allowLogin || !username || !password || login.isLoading"
class="kuiButton kuiButton--primary kuiButton--fullWidth"
data-test-subj="loginSubmit"
>
Log in
</button>
</div>
</form>
</div>
<div class="euiText loginErrorEsUnavailable" ng-if="login.layout === 'error-es-unavailable'">
<p class="euiTitle euiTitle--medium euiTextColor euiTextColor--danger">Cannot connect to the Elasticsearch cluster currently configured for Kibana.</p>
<p>Refer to the Kibana logs for more details and refresh to try again.</p>
</div>
<div class="euiText loginErrorXpackUnavailable" ng-if="login.layout === 'error-xpack-unavailable'">
<p class="euiTitle euiTitle--small">It appears you're running the oss-only distribution of Elasticsearch.</p>
<p class="euiTitle euiTitle--small">To use the full set of free features in this distribution of Kibana, please update Elasticsearch to the <a href="https://www.elastic.co/downloads/elasticsearch">default distribution</a>.</p>
<p>Refresh to try again.</p>
</div>
</div>
<div id="reactLoginRoot" />

View file

@ -1,50 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { parse } from 'url';
import { get } from 'lodash';
import 'ui/autoload/styles';
import 'plugins/security/views/login/login.less';
import chrome from 'ui/chrome';
import { parseNext } from 'plugins/security/lib/parse_next';
import template from 'plugins/security/views/login/login.html';
const messageMap = {
SESSION_EXPIRED: 'Your session has expired. Please log in again.'
};
chrome
.setVisible(false)
.setRootTemplate(template)
.setRootController('login', function ($http, $window, secureCookies, loginState) {
const basePath = chrome.getBasePath();
const next = parseNext($window.location.href, basePath);
const isSecure = !!$window.location.protocol.match(/^https/);
const self = this;
function setupScope() {
self.layout = loginState.layout;
self.allowLogin = loginState.allowLogin;
self.loginMessage = loginState.loginMessage;
self.infoMessage = get(messageMap, parse($window.location.href, true).query.msg);
self.isDisabled = !isSecure && secureCookies;
self.isLoading = false;
self.submit = (username, password) => {
self.isLoading = true;
self.error = false;
$http.post('./api/security/v1/login', { username, password }).then(
() => $window.location.href = next,
() => {
setupScope();
self.error = true;
self.isLoading = false;
}
);
};
}
setupScope();
});

View file

@ -1,125 +1,7 @@
@import "~ui/styles/variables/colors.less";
@import "~ui/styles/variables/bootstrap-mods.less";
@import "~ui/styles/variables/for-theme.less";
@import '~plugins/xpack_main/style/main.less';
.application {
background: @globalColorTeal;
.loginWelcome::before {
content: url(../../assets/bg_top_branded.svg);
}
.inner-addon {
position: relative;
.loginWelcome::after {
content: url(../../assets/bg_bottom_branded.svg);
}
.inner-addon .fa {
position: absolute;
padding: 12px;
pointer-events: none;
color: @gray2;
}
.left-addon .fa { left: 0;}
.right-addon .fa { right: 0;}
.left-addon input, .right-addon input {
padding-left: 35px !important;
}
.container {
width: 50%;
height: 100vh;
padding: 0;
text-align: center;
background: @globalColorLightestGray;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
> div {
float: left;
}
.logo-container {
width: 120px;
height: 120px;
padding: 20px;
background-color: @globalColorWhite;
border-radius: 50%;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2);
position: absolute;
top: 50%;
right: -60px;
margin-top: -60px;
text-align: center;
.kibanaWelcomeLogo {
width: 60px;
height: 60px;
margin: 10px 0px 10px 20px;
}
}
.login-form {
width: 300px;
margin-top: 20px;
.form-group.error-message {
color: @globalColorRed;
}
}
.warning-container {
background-color: #e00;
color: #fff;
padding: 20px;
width: 100%;
margin-top: 10px;
}
.info-container {
background-color: @brand-info;
color: #fff;
padding: 20px;
width: 100%;
margin-top: 10px;
}
}
.loginButton {
display: block;
width: 100%;
color: white;
font-size: 1.25em;
border: none;
margin-bottom: 0;
padding: 5px 15px;
font-weight: normal;
text-align: center;
line-height: 1.42857143;
border-radius: 4px;
vertical-align: middle;
text-transform: uppercase;
background-color: #006E8A;
&:focus:not(:disabled),
&:hover:not(:disabled) {
background-color: darken(#006E8A, 10%);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
input.form-control {
font-size: 1.125em;
height: auto;
}
.loginErrorEsUnavailable,
.loginErrorXpackUnavailable {
width: 600px;
z-index: 10;
}

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { get } from 'lodash';
import { parseNext } from 'plugins/security/lib/parse_next';
import { LoginPage } from 'plugins/security/views/login/components';
// @ts-ignore
import template from 'plugins/security/views/login/login.html';
import React from 'react';
import { render } from 'react-dom';
import 'ui/autoload/styles';
import chrome from 'ui/chrome';
import { parse } from 'url';
import { LoginState } from '../../../common/login_state';
import './login.less';
const messageMap = {
SESSION_EXPIRED: 'Your session has timed out. Please log in again.',
};
interface AnyObject {
[key: string]: any;
}
(chrome as AnyObject)
.setVisible(false)
.setRootTemplate(template)
.setRootController(
'login',
(
$scope: AnyObject,
$http: AnyObject,
$window: AnyObject,
secureCookies: boolean,
loginState: LoginState
) => {
const basePath = chrome.getBasePath();
const next = parseNext($window.location.href, basePath);
const isSecure = !!$window.location.protocol.match(/^https/);
$scope.$$postDigest(() => {
const domNode = document.getElementById('reactLoginRoot');
const msgQueryParam = parse($window.location.href, true).query.msg || '';
render(
<LoginPage
http={$http}
window={$window}
infoMessage={get(messageMap, msgQueryParam)}
loginState={loginState}
isSecureConnection={isSecure}
requiresSecureConnection={secureCookies}
next={next}
/>,
domNode
);
});
}
);

View file

@ -13,7 +13,6 @@
* security in roles.
* @property {boolean} allowRoleFieldLevelSecurity Indicates whether we allow users to define field level security
* in roles
* @property {string} [loginMessage] Message to show at the login page.
* @property {string} [linksMessage] Message to show when security links are clicked throughout the kibana app.
*/

View file

@ -32,7 +32,7 @@ export default function ({ getService, getPageObjects }) {
it('displays message if login fails', async () => {
await PageObjects.security.loginPage.login('wrong-user', 'wrong-password', { expectSuccess: false });
const errorMessage = await PageObjects.security.loginPage.getErrorMessage();
expect(errorMessage).to.be('Oops! Error. Try again.');
expect(errorMessage).to.be('Invalid username or password. Please try again.');
});
});
});

View file

@ -16,6 +16,9 @@
"plugins/xpack_main/*": [
"x-pack/plugins/xpack_main/public/*"
],
"plugins/security/*": [
"x-pack/plugins/security/public/*"
],
"plugins/spaces/*": [
"x-pack/plugins/spaces/public/*"
]