Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Implement Kibana Login Selector #53010

Merged
merged 16 commits into from
Mar 23, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions packages/kbn-config-schema/src/internals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,8 @@ export const internals = Joi.extend([
for (const [entryKey, entryValue] of value) {
const { value: validatedEntryKey, error: keyError } = Joi.validate(
entryKey,
params.key
params.key,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: This fixes the issue that's is a blocker for us (without this we can't have required fields within schema.recordOf value schema), I'll prepare a separate PR for kbn/config-schema next week or ask Platform team for a help if it turns out to be more complex than I think.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handled in #60406.

{ presence: 'required' }
);

if (keyError) {
Expand All @@ -323,7 +324,8 @@ export const internals = Joi.extend([

const { value: validatedEntryValue, error: valueError } = Joi.validate(
entryValue,
params.value
params.value,
{ presence: 'required' }
);

if (valueError) {
Expand Down Expand Up @@ -374,7 +376,8 @@ export const internals = Joi.extend([
for (const [entryKey, entryValue] of Object.entries(value)) {
const { value: validatedEntryKey, error: keyError } = Joi.validate(
entryKey,
params.key
params.key,
{ presence: 'required' }
);

if (keyError) {
Expand All @@ -383,7 +386,8 @@ export const internals = Joi.extend([

const { value: validatedEntryValue, error: valueError } = Joi.validate(
entryValue,
params.value
params.value,
{ presence: 'required' }
);

if (valueError) {
Expand Down
5 changes: 1 addition & 4 deletions x-pack/legacy/plugins/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,7 @@ export const security = (kibana: Record<string, any>) =>
uiExports: {
hacks: ['plugins/security/hacks/legacy'],
injectDefaultVars: (server: Server) => {
return {
secureCookies: getSecurityPluginSetup(server).__legacyCompat.config.secureCookies,
enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'),
};
return { enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled') };
},
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { LoginLayout } from '../../../common/licensing';
import { LoginLayout } from './licensing';

interface LoginSelector {
enabled: boolean;
providers: Array<{ type: string; name: string; options: { description: string; order: number } }>;
}

export interface LoginState {
layout: LoginLayout;
allowLogin: boolean;
showLoginForm: boolean;
requiresSecureConnection: boolean;
selector: LoginSelector;
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@
max-width: 700px;
}
}

.loginWelcome__selectorButton {
margin-bottom: $euiSize;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, instead of using margin, you can use EuiSpacer

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I was torn on this and decided to go with CSS solution. But I don't have strong opinion here. Do we recommend using EuiSpacer in cases like this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes you can find a link to the docs in the comment above. Thanks!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, actually I use this margin for the elements that are rendered in the loop, but it seems we don't recommend using spacer in such case:

You should not use it in loops of repeatable components. In those situations it is almost always more preferable to define the spacing on the component itself.

I'll see if I can turn the element I render into component.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah sorry that is an old warning. I actually have a branch going that will remove that message from our docs. It is ok to use EuiSpacer in repeatable loops.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, replaced with EuiSpacer, thanks!

}
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ export const loginApp = Object.freeze({
http: coreStart.http,
fatalErrors: coreStart.fatalErrors,
loginAssistanceMessage: config.loginAssistanceMessage,
requiresSecureConnection: coreStart.injectedMetadata.getInjectedVar(
'secureCookies'
) as boolean,
});
},
});
Expand Down
219 changes: 140 additions & 79 deletions x-pack/plugins/security/public/authentication/login/login_page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,17 @@ import ReactDOM from 'react-dom';
import classNames from 'classnames';
import { BehaviorSubject } from 'rxjs';
import { parse } from 'url';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { EuiButton, EuiIcon, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { CoreStart, FatalErrorsStart, HttpStart } from 'src/core/public';
import { LoginLayout } from '../../../common/licensing';
import { LoginState } from '../../../common/login_state';
import { BasicLoginForm, DisabledLoginForm } from './components';
import { LoginState } from './login_state';

interface Props {
http: HttpStart;
fatalErrors: FatalErrorsStart;
loginAssistanceMessage: string;
requiresSecureConnection: boolean;
}

interface State {
Expand Down Expand Up @@ -67,12 +65,10 @@ export class LoginPage extends Component<Props, State> {
}

const isSecureConnection = !!window.location.protocol.match(/^https/);
const { allowLogin, layout } = loginState;
const { allowLogin, layout, requiresSecureConnection } = loginState;

const loginIsSupported =
this.props.requiresSecureConnection && !isSecureConnection
? false
: allowLogin && layout === 'form';
requiresSecureConnection && !isSecureConnection ? false : allowLogin && layout === 'form';

const contentHeaderClasses = classNames('loginWelcome__content', 'eui-textCenter', {
['loginWelcome__contentDisabledForm']: !loginIsSupported,
Expand Down Expand Up @@ -110,22 +106,40 @@ export class LoginPage extends Component<Props, State> {
</div>
</header>
<div className={contentBodyClasses}>
<EuiFlexGroup gutterSize="l">
<EuiFlexItem>{this.getLoginForm({ isSecureConnection, layout })}</EuiFlexItem>
</EuiFlexGroup>
{this.getLoginForm({ ...loginState, isSecureConnection })}
</div>
</div>
);
}

private getLoginForm = ({
isSecureConnection,
layout,
}: {
isSecureConnection: boolean;
layout: LoginLayout;
}) => {
if (this.props.requiresSecureConnection && !isSecureConnection) {
requiresSecureConnection,
isSecureConnection,
selector,
showLoginForm,
}: LoginState & { isSecureConnection: boolean }) => {
const showLoginSelector = selector.providers.length > 0;
if (!showLoginSelector && !showLoginForm) {
return (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.noLoginMethodsAvailableTitle"
defaultMessage="Login is disabled."
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.noLoginMethodsAvailableMessage"
defaultMessage="Contact your system administrator."
/>
}
/>
);
}

if (requiresSecureConnection && !isSecureConnection) {
return (
<DisabledLoginForm
title={
Expand All @@ -144,69 +158,116 @@ export class LoginPage extends Component<Props, State> {
);
}

switch (layout) {
case 'form':
return (
<BasicLoginForm
http={this.props.http}
infoMessage={infoMessageMap.get(
parse(window.location.href, true).query.msg?.toString()
)}
loginAssistanceMessage={this.props.loginAssistanceMessage}
/>
);
case 'error-es-unavailable':
return (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.esUnavailableTitle"
defaultMessage="Cannot connect to the Elasticsearch cluster"
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.esUnavailableMessage"
defaultMessage="See the Kibana logs for details and try reloading the page."
/>
}
/>
);
case 'error-xpack-unavailable':
return (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.xpackUnavailableTitle"
defaultMessage="Cannot connect to the Elasticsearch cluster currently configured for Kibana."
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.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="xpack.security.loginPage.unknownLayoutTitle"
defaultMessage="Unsupported login form layout."
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.unknownLayoutMessage"
defaultMessage="Refer to the Kibana logs for more details and refresh to try again."
/>
}
/>
);
if (layout === 'error-es-unavailable') {
return (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.esUnavailableTitle"
defaultMessage="Cannot connect to the Elasticsearch cluster"
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.esUnavailableMessage"
defaultMessage="See the Kibana logs for details and try reloading the page."
/>
}
/>
);
}

if (layout === 'error-xpack-unavailable') {
return (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.xpackUnavailableTitle"
defaultMessage="Cannot connect to the Elasticsearch cluster currently configured for Kibana."
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.xpackUnavailableMessage"
defaultMessage="To use the full set of free features in this distribution of Kibana, please update Elasticsearch to the default distribution."
/>
}
/>
);
}

if (layout !== 'form') {
return (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.unknownLayoutTitle"
defaultMessage="Unsupported login form layout."
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.unknownLayoutMessage"
defaultMessage="Refer to the Kibana logs for more details and refresh to try again."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: for consistency with the other messages

Suggested change
defaultMessage="Refer to the Kibana logs for more details and refresh to try again."
defaultMessage="See the Kibana logs for details and try reloading the page."

/>
}
/>
);
}

const loginSelector =
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: we probably would need a separate component for that button, but it doesn't feel mandatory at this point.

showLoginSelector &&
selector.providers.map((provider, index) => (
<EuiButton
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel free to defer this, but we might want to disable the button after clicking (and show a progress animation) like we do for the primary "Log in" button. Authentication usually happens very quickly when running locally on an untaxed cluster, but I've seen even basic/token login take a visible amount of time in other setups. Disabling the button would give us two benefits:

  1. Provides visual feedback that we're working on something
  2. Prevents users from clicking the button multiple times. I don't know how each provider would handle concurrent authentication requests

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we would need to disable all buttons, including login form ones. The more I think about it the more it feels natural to just replace BasicLoginForm with more advanced component that would also manage/render these buttons.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds good to me. We can address this in a followup so as not to block merging.

key={index}
className="loginWelcome__selectorButton"
iconType="user"
fullWidth={true}
onClick={() => this.login(provider.type, provider.name)}
>
{provider.options.description}
</EuiButton>
));

const loginSelectorAndLoginFormSeparator = showLoginSelector && showLoginForm && (
<>
<EuiText textAlign="center" color="subdued">
―――&nbsp;&nbsp;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: didn't have chance to find a better solution for this nasty ―――&nbsp;&nbsp; temporary workaround yet, thinking...

<FormattedMessage id="xpack.security.loginPage.loginSelectorOR" defaultMessage="OR" />
&nbsp;&nbsp;―――
</EuiText>
<EuiSpacer size="m" />
</>
);

const loginForm = showLoginForm && (
<BasicLoginForm
http={this.props.http}
infoMessage={infoMessageMap.get(parse(window.location.href, true).query.msg?.toString())}
loginAssistanceMessage={this.props.loginAssistanceMessage}
/>
);

return (
<>
{loginSelector}
{loginSelectorAndLoginFormSeparator}
{loginForm}
</>
);
};

private login = (providerType: string, providerName: string) => {
const query = parse(window.location.href, true).query;

This comment was marked as resolved.

const next =
Array.isArray(query.next) && query.next.length > 0 ? query.next[0] : (query.next as string);
const queryString = next ? `?next=${encodeURIComponent(next)}${window.location.hash}` : '';

window.location.href = `${
this.props.http.basePath.serverBasePath
}/internal/security/login/${encodeURIComponent(providerType)}/${encodeURIComponent(
providerName
)}${queryString}`;
};
}

Expand Down
Loading