[Custom Branding] Fetch custom branding on unauthenticated pages (#149207)

## Summary
Addresses: https://github.com/elastic/kibana/issues/148898

This PR adds custom branding to the login page. In order to do so,
`customBranding` plugin utilizes the saved object client with
`SECURITY_EXTENSION_ID` excluded. This PR also adds the appropriate
license check for fetching of custom branding.


I've also added some minor fixes to `template.tsx` and
`loading_indicator.tsx` as there were some errors in the logic and the
places that I've missed in the first iteration.

### Checklist

Delete any items that are not applicable to this PR.

- [X] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [X]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [X] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [X] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [X] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
~- [X] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)~
- [X] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [X] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Maja Grubic 2023-01-31 10:01:35 +01:00 committed by GitHub
parent ae07320ca1
commit 3592cebe6e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 420 additions and 72 deletions

View file

@ -20,11 +20,11 @@ exports[`kbnLoadingIndicator is visible when loadingCount is > 0 1`] = `
/>
`;
exports[`kbnLoadingIndicator shows EuiLoadingSpinner when showPlainSpinner is true 1`] = `
<EuiLoadingSpinner
aria-hidden={false}
aria-label="Loading content"
data-test-subj="globalLoadingIndicator"
size="l"
exports[`kbnLoadingIndicator shows logo image when customLogo is set 1`] = `
<EuiImage
alt="logo"
aria-label="User logo"
size={24}
src="customLogo"
/>
`;

View file

@ -102,10 +102,7 @@ export function HeaderLogo({ href, navigateToApp, loadingCount$, ...observables
defaultMessage: 'Elastic home',
})}
>
<LoadingIndicator
loadingCount$={loadingCount$!}
showPlainSpinner={Boolean(logo || customizedLogo)}
/>
<LoadingIndicator loadingCount$={loadingCount$!} customLogo={logo} />
{customizedLogo ? (
<img src={customizedLogo} width="200" height="84" alt="custom mark" />
) : (

View file

@ -28,9 +28,9 @@ describe('kbnLoadingIndicator', () => {
expect(wrapper).toMatchSnapshot();
});
it('shows EuiLoadingSpinner when showPlainSpinner is true', () => {
it('shows logo image when customLogo is set', () => {
const wrapper = shallow(
<LoadingIndicator loadingCount$={new BehaviorSubject(1)} showPlainSpinner={true} />
<LoadingIndicator loadingCount$={new BehaviorSubject(1)} customLogo={'customLogo'} />
);
// Pause the check beyond the 250ms delay that it has
setTimeout(() => {

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { EuiLoadingSpinner, EuiProgress, EuiIcon } from '@elastic/eui';
import { EuiLoadingSpinner, EuiProgress, EuiIcon, EuiImage } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import classNames from 'classnames';
@ -18,7 +18,7 @@ import './loading_indicator.scss';
export interface LoadingIndicatorProps {
loadingCount$: ReturnType<HttpStart['getLoadingCount$']>;
showAsBar?: boolean;
showPlainSpinner?: boolean;
customLogo?: string;
}
export class LoadingIndicator extends React.Component<LoadingIndicatorProps, { visible: boolean }> {
@ -58,10 +58,9 @@ export class LoadingIndicator extends React.Component<LoadingIndicatorProps, { v
render() {
const className = classNames(!this.state.visible && 'kbnLoadingIndicator-hidden');
const testSubj =
this.state.visible || this.props.showPlainSpinner
? 'globalLoadingIndicator'
: 'globalLoadingIndicator-hidden';
const testSubj = this.state.visible
? 'globalLoadingIndicator'
: 'globalLoadingIndicator-hidden';
const ariaHidden = !this.state.visible;
@ -69,25 +68,37 @@ export class LoadingIndicator extends React.Component<LoadingIndicatorProps, { v
defaultMessage: 'Loading content',
});
const logo =
this.state.visible || this.props.showPlainSpinner ? (
<EuiLoadingSpinner
size="l"
data-test-subj={testSubj}
aria-hidden={false}
aria-label={ariaLabel}
/>
) : (
<EuiIcon
type={'logoElastic'}
size="l"
data-test-subj={testSubj}
className="chrHeaderLogo__cluster"
aria-label={i18n.translate('core.ui.chrome.headerGlobalNav.logoAriaLabel', {
defaultMessage: 'Elastic Logo',
})}
/>
);
const logoImage = this.props.customLogo ? (
<EuiImage
src={this.props.customLogo}
size={24}
alt="logo"
aria-label={i18n.translate('core.ui.chrome.headerGlobalNav.customLogoAriaLabel', {
defaultMessage: 'User logo',
})}
/>
) : (
<EuiIcon
type={'logoElastic'}
size="l"
data-test-subj={testSubj}
className="chrHeaderLogo__cluster"
aria-label={i18n.translate('core.ui.chrome.headerGlobalNav.logoAriaLabel', {
defaultMessage: 'Elastic Logo',
})}
/>
);
const logo = this.state.visible ? (
<EuiLoadingSpinner
size="l"
data-test-subj={testSubj}
aria-hidden={false}
aria-label={ariaLabel}
/>
) : (
logoImage
);
return !this.props.showAsBar ? (
logo

View file

@ -61,4 +61,16 @@ describe('#setup', () => {
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(customBranding).toEqual({ logo: 'myLogo' });
});
it('calls fetchFn correctly when unauthenticated', async () => {
const service = new CustomBrandingService(coreContext);
const { register, getBrandingFor } = service.setup();
service.start();
const fetchFn = jest.fn();
fetchFn.mockImplementation(() => Promise.resolve({ logo: 'myLogo' }));
register('customBranding', fetchFn);
const kibanaRequest: jest.Mocked<KibanaRequest> = {} as unknown as jest.Mocked<KibanaRequest>;
await getBrandingFor(kibanaRequest, { unauthenticated: true });
expect(fetchFn).toHaveBeenCalledWith(kibanaRequest, true);
});
});

View file

@ -10,13 +10,15 @@ import type { CustomBranding } from '@kbn/core-custom-branding-common';
import type { KibanaRequest } from '@kbn/core-http-server';
import type { CoreContext } from '@kbn/core-base-server-internal';
import type { Logger } from '@kbn/logging';
/**
* @internal
*/
export interface InternalCustomBrandingSetup {
register: (pluginName: string, fetchFn: CustomBrandingFetchFn) => void;
getBrandingFor: (request: KibanaRequest) => Promise<CustomBranding>;
getBrandingFor: (
request: KibanaRequest,
options?: { unauthenticated?: boolean }
) => Promise<CustomBranding>;
}
export class CustomBrandingService {
@ -55,13 +57,16 @@ export class CustomBrandingService {
public stop() {}
private getBrandingFor = async (request: KibanaRequest): Promise<CustomBranding> => {
private getBrandingFor = async (
request: KibanaRequest,
options?: { unauthenticated?: boolean }
): Promise<CustomBranding> => {
if (!this.startCalled) {
throw new Error('Cannot be called before #start');
}
if (!this.pluginName || this.pluginName !== 'customBranding' || !this.fetchFn) {
return {};
}
return this.fetchFn!(request);
return this.fetchFn!(request, Boolean(options?.unauthenticated));
};
}

View file

@ -13,9 +13,16 @@ import type { MaybePromise } from '@kbn/utility-types';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface CustomBrandingStart {}
export type CustomBrandingFetchFn = (request: KibanaRequest) => MaybePromise<CustomBranding>;
export type CustomBrandingFetchFn = (
request: KibanaRequest,
unauthenticated: boolean
) => MaybePromise<CustomBranding>;
/** @public */
export interface CustomBrandingSetup {
register: (fetchFn: CustomBrandingFetchFn) => void;
getBrandingFor: (
request: KibanaRequest,
options: { unauthenticated?: boolean }
) => Promise<CustomBranding>;
}

View file

@ -71,5 +71,5 @@ export interface InjectedMetadata {
user: Record<string, any>; // unreferencing UserProvidedValues here
};
};
customBranding: Pick<CustomBranding, 'logo' | 'customizedLogo'>;
customBranding: Pick<CustomBranding, 'logo' | 'customizedLogo' | 'pageTitle'>;
}

View file

@ -198,6 +198,7 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
register: (fetchFn) => {
deps.customBranding.register(plugin.name, fetchFn);
},
getBrandingFor: deps.customBranding.getBrandingFor,
},
docLinks: deps.docLinks,
elasticsearch: {

View file

@ -113,15 +113,18 @@ export class RenderingService {
let branding: CustomBranding = {};
try {
// Only provide the clusterInfo if the request is authenticated and the elasticsearch service is available.
if (isAuthenticated(http.auth, request) && elasticsearch) {
const authenticated = isAuthenticated(http.auth, request);
if (authenticated && elasticsearch) {
clusterInfo = await firstValueFrom(
elasticsearch.clusterInfo$.pipe(
timeout(50), // If not available, just return undefined
catchError(() => of({}))
)
);
branding = await customBranding?.getBrandingFor(request);
}
branding = await customBranding?.getBrandingFor(request, {
unauthenticated: !authenticated,
})!;
} catch (err) {
// swallow error
}
@ -151,6 +154,7 @@ export class RenderingService {
faviconSVG: branding?.faviconSVG,
faviconPNG: branding?.faviconPNG,
pageTitle: branding?.pageTitle,
logo: branding?.logo,
},
injectedMetadata: {
version: env.packageInfo.version,

View file

@ -34,6 +34,11 @@ export const Template: FunctionComponent<Props> = ({
const title = customBranding.pageTitle ?? 'Elastic';
const favIcon = customBranding.faviconSVG ?? `${uiPublicUrl}/favicons/favicon.svg`;
const favIconPng = customBranding.faviconPNG ?? `${uiPublicUrl}/favicons/favicon.png`;
const logo = customBranding.logo ? (
<img src={customBranding.logo} width="64" height="64" alt="logo" />
) : (
<Logo />
);
return (
<html lang={locale}>
<head>
@ -67,7 +72,7 @@ export const Template: FunctionComponent<Props> = ({
data-test-subj="kbnLoadingMessage"
>
<div className="kbnLoaderWrap">
<Logo />
{logo}
<div
className="kbnWelcomeText"
data-error-message={i18n('core.ui.welcomeErrorMessage', {
@ -75,14 +80,16 @@ export const Template: FunctionComponent<Props> = ({
'Elastic did not load properly. Check the server output for more information.',
})}
>
{i18n('core.ui.welcomeMessage', { defaultMessage: 'Loading Elastic' })}
{i18n('core.ui.welcomeMessage', {
defaultMessage: 'Loading Elastic',
})}
</div>
<div className="kbnProgress" />
</div>
</div>
<div className="kbnWelcomeView" id="kbn_legacy_browser_error" style={{ display: 'none' }}>
<Logo />
{logo}
<h2 className="kbnWelcomeTitle">
{i18n('core.ui.legacyBrowserTitle', {

View file

@ -209,6 +209,8 @@ export type {
export type { ToastsApi } from '@kbn/core-notifications-browser-internal';
export type { CustomBrandingStart, CustomBrandingSetup } from '@kbn/core-custom-branding-browser';
export type { ThemeServiceSetup, ThemeServiceStart, CoreTheme } from '@kbn/core-theme-browser';
export type {

View file

@ -530,3 +530,5 @@ export type {
PublicHttpServiceSetup as HttpServiceSetup,
HttpServiceSetup as BaseHttpServiceSetup,
};
export type { CustomBrandingSetup } from '@kbn/core-custom-branding-server';

View file

@ -146,6 +146,8 @@
"@kbn/core-mount-utils-browser",
"@kbn/core-execution-context-browser",
"@kbn/core-lifecycle-browser",
"@kbn/core-custom-branding-browser",
"@kbn/core-custom-branding-server",
],
"exclude": [
"target/**/*",

View file

@ -13,10 +13,12 @@ import {
Logger,
Plugin,
PluginInitializerContext,
SECURITY_EXTENSION_ID,
} from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
import { License } from '@kbn/license-api-guard-plugin/server';
import { CustomBranding } from '@kbn/core-custom-branding-common';
import { Subscription } from 'rxjs';
import { PLUGIN } from '../common/constants';
import type { CustomBrandingRequestHandlerContext } from './types';
import { Dependencies } from './types';
@ -33,6 +35,8 @@ const settingsKeys: Array<keyof CustomBranding> = [
export class CustomBrandingPlugin implements Plugin {
private readonly license: License;
private readonly logger: Logger;
private licensingSubscription?: Subscription;
private isValidLicense: boolean = false;
constructor(initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get();
@ -48,14 +52,25 @@ export class CustomBrandingPlugin implements Plugin {
const router = core.http.createRouter<CustomBrandingRequestHandlerContext>();
registerRoutes(router);
const fetchFn = async (request: KibanaRequest): Promise<CustomBranding> => {
const fetchFn = async (
request: KibanaRequest,
unauthenticated: boolean
): Promise<CustomBranding> => {
if (!this.isValidLicense) {
return {};
}
const [coreStart] = await core.getStartServices();
const soClient = coreStart.savedObjects.getScopedClient(request);
const soClient = unauthenticated
? coreStart.savedObjects.getScopedClient(request, {
excludedExtensions: [SECURITY_EXTENSION_ID],
})
: coreStart.savedObjects.getScopedClient(request);
const uiSettings = coreStart.uiSettings.globalAsScopedToClient(soClient);
return await this.getBrandingFrom(uiSettings);
};
core.customBranding.register(fetchFn);
return {};
}
@ -66,10 +81,15 @@ export class CustomBrandingPlugin implements Plugin {
minimumLicenseType: PLUGIN.MINIMUM_LICENSE_REQUIRED,
licensing,
});
this.licensingSubscription = licensing.license$.subscribe((next) => {
this.isValidLicense = next.hasAtLeast('enterprise');
});
return {};
}
public stop() {}
public stop() {
this.licensingSubscription?.unsubscribe();
}
private getBrandingFrom = async (uiSettingsClient: IUiSettingsClient) => {
const branding: CustomBranding = {};

View file

@ -7,12 +7,13 @@
import './authentication_state_page.scss';
import { EuiIcon, EuiSpacer, EuiTitle } from '@elastic/eui';
import { EuiIcon, EuiImage, EuiSpacer, EuiTitle } from '@elastic/eui';
import React from 'react';
interface Props {
className?: string;
title: React.ReactNode;
logo?: string;
}
export const AuthenticationStatePage: React.FC<Props> = (props) => (
@ -21,7 +22,11 @@ export const AuthenticationStatePage: React.FC<Props> = (props) => (
<div className="secAuthenticationStatePage__content eui-textCenter">
<EuiSpacer size="xxl" />
<span className="secAuthenticationStatePage__logo">
<EuiIcon type="logoElastic" size="xxl" />
{props.logo ? (
<EuiImage src={props.logo} size={40} alt={'logo'} />
) : (
<EuiIcon type="logoElastic" size="xxl" />
)}
</span>
<EuiTitle size="l" className="secAuthenticationStatePage__title">
<h1>{props.title}</h1>

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
jest.mock('./logged_out_page');
import type { AppMount } from '@kbn/core/public';
@ -56,7 +55,7 @@ describe('loggedOutApp', () => {
expect(mockRenderApp).toHaveBeenCalledWith(
coreStartMock.i18n,
{ element: appMountParams.element, theme$: appMountParams.theme$ },
{ basePath: coreStartMock.http.basePath }
{ basePath: coreStartMock.http.basePath, customBranding: coreStartMock.customBranding }
);
});
});

View file

@ -36,7 +36,7 @@ export const loggedOutApp = Object.freeze({
return renderLoggedOutPage(
coreStart.i18n,
{ element, theme$ },
{ basePath: coreStart.http.basePath }
{ basePath: coreStart.http.basePath, customBranding: coreStart.customBranding }
);
},
});

View file

@ -8,6 +8,7 @@
import { EuiButton } from '@elastic/eui';
import React from 'react';
import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks';
import { coreMock } from '@kbn/core/public/mocks';
import { mountWithIntl } from '@kbn/test-jest-helpers';
@ -23,7 +24,10 @@ describe('LoggedOutPage', () => {
it('points to a base path if `next` parameter is not provided', async () => {
const basePathMock = coreMock.createStart({ basePath: '/mock-base-path' }).http.basePath;
const wrapper = mountWithIntl(<LoggedOutPage basePath={basePathMock} />);
const customBranding = customBrandingServiceMock.createStartContract();
const wrapper = mountWithIntl(
<LoggedOutPage basePath={basePathMock} customBranding={customBranding} />
);
expect(wrapper.find(EuiButton).prop('href')).toBe('/mock-base-path/');
});
@ -33,8 +37,11 @@ describe('LoggedOutPage', () => {
'/mock-base-path/app/home#/?_g=()'
)}`;
const customBranding = customBrandingServiceMock.createStartContract();
const basePathMock = coreMock.createStart({ basePath: '/mock-base-path' }).http.basePath;
const wrapper = mountWithIntl(<LoggedOutPage basePath={basePathMock} />);
const wrapper = mountWithIntl(
<LoggedOutPage basePath={basePathMock} customBranding={customBranding} />
);
expect(wrapper.find(EuiButton).prop('href')).toBe('/mock-base-path/app/home#/?_g=()');
});

View file

@ -8,8 +8,14 @@
import { EuiButton } from '@elastic/eui';
import React from 'react';
import ReactDOM from 'react-dom';
import useObservable from 'react-use/lib/useObservable';
import type { AppMountParameters, CoreStart, IBasePath } from '@kbn/core/public';
import type {
AppMountParameters,
CoreStart,
CustomBrandingStart,
IBasePath,
} from '@kbn/core/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
@ -18,9 +24,11 @@ import { AuthenticationStatePage } from '../components';
interface Props {
basePath: IBasePath;
customBranding: CustomBrandingStart;
}
export function LoggedOutPage({ basePath }: Props) {
export function LoggedOutPage({ basePath, customBranding }: Props) {
const customBrandingValue = useObservable(customBranding.customBranding$);
return (
<AuthenticationStatePage
title={
@ -29,6 +37,7 @@ export function LoggedOutPage({ basePath }: Props) {
defaultMessage="Successfully logged out"
/>
}
logo={customBrandingValue?.logo}
>
<EuiButton href={parseNext(window.location.href, basePath.serverBasePath)}>
<FormattedMessage id="xpack.security.loggedOut.login" defaultMessage="Log in" />

View file

@ -395,3 +395,91 @@ exports[`LoginPage page renders as expected 1`] = `
</div>
</div>
`;
exports[`LoginPage page renders with custom branding 1`] = `
<div
className="loginWelcome login-form"
>
<header
className="loginWelcome__header"
>
<div
className="loginWelcome__content eui-textCenter"
>
<EuiSpacer
size="xxl"
/>
<span
className="loginWelcome__logo"
>
<EuiImage
alt="logo"
size={40}
src="logo"
/>
</span>
<EuiTitle
className="loginWelcome__title"
data-test-subj="loginWelcomeTitle"
size="m"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Elastic"
id="xpack.security.loginPage.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="loginWelcome__content loginWelcome-body"
>
<EuiFlexGroup
gutterSize="l"
>
<EuiFlexItem>
<LoginForm
http={
Object {
"addLoadingCountSource": [MockFunction],
"get": [MockFunction],
}
}
loginAssistanceMessage=""
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,
},
],
}
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
`;

View file

@ -64,6 +64,7 @@ describe('loginApp', () => {
{ element: appMountParams.element, theme$: appMountParams.theme$ },
{
http: coreStartMock.http,
customBranding: coreStartMock.customBranding,
notifications: coreStartMock.notifications,
fatalErrors: coreStartMock.fatalErrors,
loginAssistanceMessage: 'some-message',

View file

@ -40,6 +40,7 @@ export const loginApp = Object.freeze({
coreStart.i18n,
{ element, theme$ },
{
customBranding: coreStart.customBranding,
http: coreStart.http,
notifications: coreStart.notifications,
fatalErrors: coreStart.fatalErrors,

View file

@ -9,7 +9,9 @@ import { EuiFlexItem } from '@elastic/eui';
import { act } from '@testing-library/react';
import { shallow } from 'enzyme';
import React from 'react';
import { of } from 'rxjs';
import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks';
import { coreMock } from '@kbn/core/public/mocks';
import { nextTick } from '@kbn/test-jest-helpers';
@ -27,6 +29,7 @@ const createLoginState = (options?: Partial<LoginState>) => {
enabled: false,
providers: [{ type: 'basic', name: 'basic1', usesLoginForm: true }],
},
customBranding: {},
...options,
} as LoginState;
};
@ -41,6 +44,7 @@ describe('LoginPage', () => {
httpMock.get.mockReset();
httpMock.addLoadingCountSource.mockReset();
};
const customBrandingMock = customBrandingServiceMock.createStartContract();
beforeEach(() => {
Object.defineProperty(window, 'location', {
@ -54,11 +58,37 @@ describe('LoginPage', () => {
describe('page', () => {
it('renders as expected', async () => {
const coreStartMock = coreMock.createStart();
customBrandingMock.customBranding$ = of({});
httpMock.get.mockResolvedValue(createLoginState());
const wrapper = shallow(
<LoginPage
http={httpMock}
customBranding={customBrandingMock}
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).toMatchSnapshot();
});
it('renders with custom branding', async () => {
const coreStartMock = coreMock.createStart();
customBrandingMock.customBranding$ = of({ logo: 'logo' });
httpMock.get.mockResolvedValue(createLoginState());
const wrapper = shallow(
<LoginPage
http={httpMock}
customBranding={customBrandingMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
@ -100,6 +130,7 @@ describe('LoginPage', () => {
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
customBranding={customBrandingMock}
/>
);
@ -121,6 +152,7 @@ describe('LoginPage', () => {
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
customBranding={customBrandingMock}
/>
);
@ -142,6 +174,7 @@ describe('LoginPage', () => {
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
customBranding={customBrandingMock}
/>
);
@ -165,6 +198,7 @@ describe('LoginPage', () => {
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
customBranding={customBrandingMock}
/>
);
@ -188,6 +222,7 @@ describe('LoginPage', () => {
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
customBranding={customBrandingMock}
/>
);
@ -219,6 +254,7 @@ describe('LoginPage', () => {
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
sameSiteCookies="Lax"
customBranding={customBrandingMock}
/>
);
@ -250,6 +286,7 @@ describe('LoginPage', () => {
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
sameSiteCookies="None"
customBranding={customBrandingMock}
/>
);
@ -276,6 +313,7 @@ describe('LoginPage', () => {
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
customBranding={customBrandingMock}
/>
);
@ -299,6 +337,7 @@ describe('LoginPage', () => {
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
customBranding={customBrandingMock}
/>
);
@ -322,6 +361,7 @@ describe('LoginPage', () => {
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
customBranding={customBrandingMock}
/>
);
@ -349,6 +389,7 @@ describe('LoginPage', () => {
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage="This is an *important* message"
customBranding={customBrandingMock}
/>
);
@ -371,6 +412,7 @@ describe('LoginPage', () => {
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
customBranding={customBrandingMock}
/>
);
@ -395,6 +437,7 @@ describe('LoginPage', () => {
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
customBranding={customBrandingMock}
/>
);
@ -420,6 +463,7 @@ describe('LoginPage', () => {
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
customBranding={customBrandingMock}
/>
);

View file

@ -12,6 +12,7 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiImage,
EuiSpacer,
EuiText,
EuiTitle,
@ -19,11 +20,14 @@ import {
import classNames from 'classnames';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import type { Subscription } from 'rxjs';
import { BehaviorSubject } from 'rxjs';
import type { CustomBranding } from '@kbn/core-custom-branding-common';
import type {
AppMountParameters,
CoreStart,
CustomBrandingStart,
FatalErrorsStart,
HttpStart,
NotificationsStart,
@ -48,10 +52,12 @@ interface Props {
fatalErrors: FatalErrorsStart;
loginAssistanceMessage: string;
sameSiteCookies?: ConfigType['sameSiteCookies'];
customBranding: CustomBrandingStart;
}
interface State {
loginState: LoginState | null;
customBranding: CustomBranding;
}
const loginFormMessages: Record<LogoutReason, NonNullable<LoginFormProps['message']>> = {
@ -83,14 +89,22 @@ const loginFormMessages: Record<LogoutReason, NonNullable<LoginFormProps['messag
};
export class LoginPage extends Component<Props, State> {
state = { loginState: null } as State;
state = { loginState: null, customBranding: {} } as State;
private customBrandingSubscription?: Subscription;
public async componentDidMount() {
const loadingCount$ = new BehaviorSubject(1);
this.customBrandingSubscription = this.props.customBranding.customBranding$.subscribe(
(next) => {
this.setState({ ...this.state, customBranding: next });
}
);
this.props.http.addLoadingCountSource(loadingCount$.asObservable());
try {
this.setState({ loginState: await this.props.http.get('/internal/security/login_state') });
this.setState({
loginState: await this.props.http.get('/internal/security/login_state'),
});
} catch (err) {
this.props.fatalErrors.add(err as Error);
}
@ -99,6 +113,10 @@ export class LoginPage extends Component<Props, State> {
loadingCount$.complete();
}
public componentWillUnmount() {
this.customBrandingSubscription?.unsubscribe();
}
public render() {
const loginState = this.state.loginState;
if (!loginState) {
@ -122,14 +140,18 @@ export class LoginPage extends Component<Props, State> {
['loginWelcome__contentDisabledForm']: !loginIsSupported,
});
const customLogo = this.state.customBranding?.logo;
const logo = customLogo ? (
<EuiImage src={customLogo} size={40} alt="logo" />
) : (
<EuiIcon type="logoElastic" size="xxl" />
);
return (
<div className="loginWelcome login-form">
<header className="loginWelcome__header">
<div className={contentHeaderClasses}>
<EuiSpacer size="xxl" />
<span className="loginWelcome__logo">
<EuiIcon type="logoElastic" size="xxl" />
</span>
<span className="loginWelcome__logo">{logo}</span>
<EuiTitle size="m" className="loginWelcome__title" data-test-subj="loginWelcomeTitle">
<h1>
<FormattedMessage

View file

@ -1,3 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UnauthenticatedPage renders as expected 1`] = `"<html lang=\\"en\\"><head><title>Elastic</title><style></style><style data-emotion=\\"eui \\"></style></style><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps-src/kbn-ui-shared-deps-src.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps-npm/kbn-ui-shared-deps-npm.v8.light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/node_modules/@kbn/ui-framework/dist/kui_light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/ui/legacy_light_theme.css\\" rel=\\"stylesheet\\"/>MockedFonts<link rel=\\"alternate icon\\" type=\\"image/png\\" href=\\"/mock-server-basepath/ui/favicons/favicon.png\\"/><link rel=\\"icon\\" type=\\"image/svg+xml\\" href=\\"/mock-server-basepath/ui/favicons/favicon.svg\\"/><meta name=\\"theme-color\\" content=\\"#ffffff\\"/><meta name=\\"color-scheme\\" content=\\"light dark\\"/></head><body><div class=\\"euiPage eui-1po165g-euiPage-row-grow\\" style=\\"min-height:100vh\\" data-test-subj=\\"promptPage\\"><div class=\\"euiPageBody eui-30ejv0-euiPageBody\\"><div class=\\"euiPanel euiPanel--plain euiPanel--paddingLarge euiPageContent euiPageContent--verticalCenter euiPageContent--horizontalCenter eui-15y4d5v-euiPanel-grow-m-l-plain-hasShadow\\" role=\\"main\\"><div class=\\"euiPanel euiPanel--transparent euiEmptyPrompt euiEmptyPrompt--vertical euiEmptyPrompt--paddingLarge eui-7iihzj-euiPanel-m-transparent\\"><div class=\\"euiEmptyPrompt__main\\"><div class=\\"euiEmptyPrompt__icon\\"><span data-euiicon-type=\\"alert\\" color=\\"danger\\"></span></div><div class=\\"euiEmptyPrompt__content\\"><div class=\\"euiEmptyPrompt__contentInner\\"><h2 class=\\"euiTitle eui-smz32e-euiTitle-m\\">We hit an authentication error</h2><div class=\\"euiSpacer euiSpacer--m eui-jv9za2-euiSpacer-m\\"></div><div class=\\"euiText eui-1mn0tf7-euiText-m-euiTextColor-subdued\\"><p>Try logging in again, and if the problem persists, contact your system administrator.</p></div><div class=\\"euiSpacer euiSpacer--l eui-p2o3x6-euiSpacer-l\\"></div><div class=\\"euiFlexGroup euiEmptyPrompt__actions eui-17kdkag-euiFlexGroup-m-center-center-column\\"><div class=\\"euiFlexItem eui-kpsrin-euiFlexItem-growZero\\"><a href=\\"/some/url?some-query=some-value#some-hash\\" rel=\\"noreferrer\\" class=\\"euiButton eui-1u1unpk-euiButtonDisplay-m-defaultMinWidth-m-fill-primary\\" data-test-subj=\\"logInButton\\"><span class=\\"eui-1km4ln8-euiButtonDisplayContent\\">Log in</span></a></div></div></div></div></div></div></div></div></div></body></html>"`;
exports[`UnauthenticatedPage renders as expected with custom title 1`] = `"<html lang=\\"en\\"><head><title>My Company Name</title><style></style><style data-emotion=\\"eui \\"></style></style><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps-src/kbn-ui-shared-deps-src.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps-npm/kbn-ui-shared-deps-npm.v8.light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/node_modules/@kbn/ui-framework/dist/kui_light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/ui/legacy_light_theme.css\\" rel=\\"stylesheet\\"/>MockedFonts<link rel=\\"alternate icon\\" type=\\"image/png\\" href=\\"/mock-server-basepath/ui/favicons/favicon.png\\"/><link rel=\\"icon\\" type=\\"image/svg+xml\\" href=\\"/mock-server-basepath/ui/favicons/favicon.svg\\"/><meta name=\\"theme-color\\" content=\\"#ffffff\\"/><meta name=\\"color-scheme\\" content=\\"light dark\\"/></head><body><div class=\\"euiPage eui-1po165g-euiPage-row-grow\\" style=\\"min-height:100vh\\" data-test-subj=\\"promptPage\\"><div class=\\"euiPageBody eui-30ejv0-euiPageBody\\"><div class=\\"euiPanel euiPanel--plain euiPanel--paddingLarge euiPageContent euiPageContent--verticalCenter euiPageContent--horizontalCenter eui-15y4d5v-euiPanel-grow-m-l-plain-hasShadow\\" role=\\"main\\"><div class=\\"euiPanel euiPanel--transparent euiEmptyPrompt euiEmptyPrompt--vertical euiEmptyPrompt--paddingLarge eui-7iihzj-euiPanel-m-transparent\\"><div class=\\"euiEmptyPrompt__main\\"><div class=\\"euiEmptyPrompt__icon\\"><span data-euiicon-type=\\"alert\\" color=\\"danger\\"></span></div><div class=\\"euiEmptyPrompt__content\\"><div class=\\"euiEmptyPrompt__contentInner\\"><h2 class=\\"euiTitle eui-smz32e-euiTitle-m\\">We hit an authentication error</h2><div class=\\"euiSpacer euiSpacer--m eui-jv9za2-euiSpacer-m\\"></div><div class=\\"euiText eui-1mn0tf7-euiText-m-euiTextColor-subdued\\"><p>Try logging in again, and if the problem persists, contact your system administrator.</p></div><div class=\\"euiSpacer euiSpacer--l eui-p2o3x6-euiSpacer-l\\"></div><div class=\\"euiFlexGroup euiEmptyPrompt__actions eui-17kdkag-euiFlexGroup-m-center-center-column\\"><div class=\\"euiFlexItem eui-kpsrin-euiFlexItem-growZero\\"><a href=\\"/some/url?some-query=some-value#some-hash\\" rel=\\"noreferrer\\" class=\\"euiButton eui-1u1unpk-euiButtonDisplay-m-defaultMinWidth-m-fill-primary\\" data-test-subj=\\"logInButton\\"><span class=\\"eui-1km4ln8-euiButtonDisplayContent\\">Log in</span></a></div></div></div></div></div></div></div></div></div></body></html>"`;

View file

@ -12,9 +12,11 @@ import { mockCanRedirectRequest } from './authentication_service.test.mocks';
import { errors } from '@elastic/elasticsearch';
import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mocks';
import type {
AuthenticationHandler,
AuthToolkit,
CustomBrandingSetup,
ElasticsearchServiceSetup,
HttpServiceSetup,
HttpServiceStart,
@ -62,6 +64,7 @@ describe('AuthenticationService', () => {
config: ConfigType;
license: jest.Mocked<SecurityLicense>;
buildNumber: number;
customBranding: jest.Mocked<CustomBrandingSetup>;
};
let mockStartAuthenticationParams: {
audit: jest.Mocked<AuditServiceSetup>;
@ -93,6 +96,7 @@ describe('AuthenticationService', () => {
}),
license: licenseMock.create(),
buildNumber: 100500,
customBranding: customBrandingServiceMock.createSetupContract(),
};
mockCanRedirectRequest.mockReturnValue(false);

View file

@ -6,6 +6,7 @@
*/
import type {
CustomBrandingSetup,
ElasticsearchServiceSetup,
HttpServiceSetup,
HttpServiceStart,
@ -37,6 +38,7 @@ import { renderUnauthenticatedPage } from './unauthenticated_page';
interface AuthenticationServiceSetupParams {
http: Pick<HttpServiceSetup, 'basePath' | 'csp' | 'registerAuth' | 'registerOnPreResponse'>;
customBranding: CustomBrandingSetup;
elasticsearch: Pick<ElasticsearchServiceSetup, 'setUnauthorizedErrorHandler'>;
config: ConfigType;
license: SecurityLicense;
@ -97,7 +99,14 @@ export class AuthenticationService {
constructor(private readonly logger: Logger) {}
setup({ config, http, license, buildNumber, elasticsearch }: AuthenticationServiceSetupParams) {
setup({
config,
http,
license,
buildNumber,
elasticsearch,
customBranding,
}: AuthenticationServiceSetupParams) {
this.license = license;
// If we cannot automatically authenticate users we should redirect them straight to the login
@ -201,8 +210,16 @@ export class AuthenticationService {
? this.authenticator.getRequestOriginalURL(request)
: `${http.basePath.get(request)}/`;
if (!isLoginPageAvailable) {
const customBrandingValue = await customBranding.getBrandingFor(request, {
unauthenticated: true,
});
return toolkit.render({
body: renderUnauthenticatedPage({ buildNumber, basePath: http.basePath, originalURL }),
body: renderUnauthenticatedPage({
buildNumber,
basePath: http.basePath,
originalURL,
customBranding: customBrandingValue,
}),
headers: { 'Content-Security-Policy': http.csp.header },
});
}

View file

@ -28,6 +28,25 @@ describe('UnauthenticatedPage', () => {
originalURL="/some/url?some-query=some-value#some-hash"
buildNumber={100500}
basePath={mockCoreSetup.http.basePath}
customBranding={{}}
/>
);
expect(body).toMatchSnapshot();
});
it('renders as expected with custom title', async () => {
const mockCoreSetup = coreMock.createSetup();
(mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation(
(path) => `/mock-basepath${path}`
);
const body = renderToStaticMarkup(
<UnauthenticatedPage
originalURL="/some/url?some-query=some-value#some-hash"
buildNumber={100500}
basePath={mockCoreSetup.http.basePath}
customBranding={{ pageTitle: 'My Company Name' }}
/>
);

View file

@ -10,6 +10,7 @@ import { EuiButton } from '@elastic/eui/lib/components/button';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { CustomBranding } from '@kbn/core-custom-branding-common';
import type { IBasePath } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
@ -20,9 +21,10 @@ interface Props {
originalURL: string;
buildNumber: number;
basePath: IBasePath;
customBranding: CustomBranding;
}
export function UnauthenticatedPage({ basePath, originalURL, buildNumber }: Props) {
export function UnauthenticatedPage({ basePath, originalURL, buildNumber, customBranding }: Props) {
return (
<PromptPage
buildNumber={buildNumber}
@ -46,6 +48,7 @@ export function UnauthenticatedPage({ basePath, originalURL, buildNumber }: Prop
/>
</EuiButton>,
]}
customBranding={customBranding}
/>
);
}

View file

@ -1,3 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ResetSessionPage renders as expected 1`] = `"<html lang=\\"en\\"><head><title>Elastic</title><style></style><style data-emotion=\\"eui \\"></style></style><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps-src/kbn-ui-shared-deps-src.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps-npm/kbn-ui-shared-deps-npm.v8.light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/node_modules/@kbn/ui-framework/dist/kui_light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/ui/legacy_light_theme.css\\" rel=\\"stylesheet\\"/>MockedFonts<link rel=\\"alternate icon\\" type=\\"image/png\\" href=\\"/mock-server-basepath/ui/favicons/favicon.png\\"/><link rel=\\"icon\\" type=\\"image/svg+xml\\" href=\\"/mock-server-basepath/ui/favicons/favicon.svg\\"/><script src=\\"/mock-basepath/internal/security/reset_session_page.js\\"></script><meta name=\\"theme-color\\" content=\\"#ffffff\\"/><meta name=\\"color-scheme\\" content=\\"light dark\\"/></head><body><div class=\\"euiPage eui-1po165g-euiPage-row-grow\\" style=\\"min-height:100vh\\" data-test-subj=\\"promptPage\\"><div class=\\"euiPageBody eui-30ejv0-euiPageBody\\"><div class=\\"euiPanel euiPanel--plain euiPanel--paddingLarge euiPageContent euiPageContent--verticalCenter euiPageContent--horizontalCenter eui-15y4d5v-euiPanel-grow-m-l-plain-hasShadow\\" role=\\"main\\"><div class=\\"euiPanel euiPanel--transparent euiEmptyPrompt euiEmptyPrompt--vertical euiEmptyPrompt--paddingLarge eui-7iihzj-euiPanel-m-transparent\\"><div class=\\"euiEmptyPrompt__main\\"><div class=\\"euiEmptyPrompt__icon\\"><span data-euiicon-type=\\"alert\\" color=\\"danger\\"></span></div><div class=\\"euiEmptyPrompt__content\\"><div class=\\"euiEmptyPrompt__contentInner\\"><h2 class=\\"euiTitle eui-smz32e-euiTitle-m\\">You do not have permission to access the requested page</h2><div class=\\"euiSpacer euiSpacer--m eui-jv9za2-euiSpacer-m\\"></div><div class=\\"euiText eui-1mn0tf7-euiText-m-euiTextColor-subdued\\"><p>Either go back to the previous page or log in as a different user.</p></div><div class=\\"euiSpacer euiSpacer--l eui-p2o3x6-euiSpacer-l\\"></div><div class=\\"euiFlexGroup euiEmptyPrompt__actions eui-17kdkag-euiFlexGroup-m-center-center-column\\"><div class=\\"euiFlexItem eui-kpsrin-euiFlexItem-growZero\\"><a href=\\"/path/to/logout\\" rel=\\"noreferrer\\" class=\\"euiButton eui-1u1unpk-euiButtonDisplay-m-defaultMinWidth-m-fill-primary\\" data-test-subj=\\"ResetSessionButton\\"><span class=\\"eui-1km4ln8-euiButtonDisplayContent\\">Log in as different user</span></a></div><div class=\\"euiFlexItem eui-kpsrin-euiFlexItem-growZero\\"><button class=\\"euiButtonEmpty eui-9t7nyf-empty-primary\\" type=\\"button\\" id=\\"goBackButton\\"><span class=\\"euiButtonContent euiButtonEmpty__content\\"><span class=\\"euiButtonEmpty__text\\">Go back</span></span></button></div></div></div></div></div></div></div></div></div></body></html>"`;
exports[`ResetSessionPage renders as expected with custom page title 1`] = `"<html lang=\\"en\\"><head><title>My Company Name</title><style></style><style data-emotion=\\"eui \\"></style></style><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps-src/kbn-ui-shared-deps-src.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps-npm/kbn-ui-shared-deps-npm.v8.light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/node_modules/@kbn/ui-framework/dist/kui_light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/ui/legacy_light_theme.css\\" rel=\\"stylesheet\\"/>MockedFonts<link rel=\\"alternate icon\\" type=\\"image/png\\" href=\\"/mock-server-basepath/ui/favicons/favicon.png\\"/><link rel=\\"icon\\" type=\\"image/svg+xml\\" href=\\"/mock-server-basepath/ui/favicons/favicon.svg\\"/><script src=\\"/mock-basepath/internal/security/reset_session_page.js\\"></script><meta name=\\"theme-color\\" content=\\"#ffffff\\"/><meta name=\\"color-scheme\\" content=\\"light dark\\"/></head><body><div class=\\"euiPage eui-1po165g-euiPage-row-grow\\" style=\\"min-height:100vh\\" data-test-subj=\\"promptPage\\"><div class=\\"euiPageBody eui-30ejv0-euiPageBody\\"><div class=\\"euiPanel euiPanel--plain euiPanel--paddingLarge euiPageContent euiPageContent--verticalCenter euiPageContent--horizontalCenter eui-15y4d5v-euiPanel-grow-m-l-plain-hasShadow\\" role=\\"main\\"><div class=\\"euiPanel euiPanel--transparent euiEmptyPrompt euiEmptyPrompt--vertical euiEmptyPrompt--paddingLarge eui-7iihzj-euiPanel-m-transparent\\"><div class=\\"euiEmptyPrompt__main\\"><div class=\\"euiEmptyPrompt__icon\\"><span data-euiicon-type=\\"alert\\" color=\\"danger\\"></span></div><div class=\\"euiEmptyPrompt__content\\"><div class=\\"euiEmptyPrompt__contentInner\\"><h2 class=\\"euiTitle eui-smz32e-euiTitle-m\\">You do not have permission to access the requested page</h2><div class=\\"euiSpacer euiSpacer--m eui-jv9za2-euiSpacer-m\\"></div><div class=\\"euiText eui-1mn0tf7-euiText-m-euiTextColor-subdued\\"><p>Either go back to the previous page or log in as a different user.</p></div><div class=\\"euiSpacer euiSpacer--l eui-p2o3x6-euiSpacer-l\\"></div><div class=\\"euiFlexGroup euiEmptyPrompt__actions eui-17kdkag-euiFlexGroup-m-center-center-column\\"><div class=\\"euiFlexItem eui-kpsrin-euiFlexItem-growZero\\"><a href=\\"/path/to/logout\\" rel=\\"noreferrer\\" class=\\"euiButton eui-1u1unpk-euiButtonDisplay-m-defaultMinWidth-m-fill-primary\\" data-test-subj=\\"ResetSessionButton\\"><span class=\\"eui-1km4ln8-euiButtonDisplayContent\\">Log in as different user</span></a></div><div class=\\"euiFlexItem eui-kpsrin-euiFlexItem-growZero\\"><button class=\\"euiButtonEmpty eui-9t7nyf-empty-primary\\" type=\\"button\\" id=\\"goBackButton\\"><span class=\\"euiButtonContent euiButtonEmpty__content\\"><span class=\\"euiButtonEmpty__text\\">Go back</span></span></button></div></div></div></div></div></div></div></div></div></body></html>"`;

View file

@ -81,6 +81,7 @@ it(`#setup returns exposed services`, () => {
features: mockFeaturesSetup,
getSpacesService: mockGetSpacesService,
getCurrentUser: jest.fn(),
customBranding: mockCoreSetup.customBranding,
});
expect(authz.actions.version).toBe('version:some-version');
@ -143,6 +144,7 @@ describe('#start', () => {
.fn()
.mockReturnValue({ getSpaceId: jest.fn(), namespaceToSpaceId: jest.fn() }),
getCurrentUser: jest.fn(),
customBranding: mockCoreSetup.customBranding,
});
const featuresStart = featuresPluginMock.createStart();
@ -215,6 +217,7 @@ it('#stop unsubscribes from license and ES updates.', async () => {
.fn()
.mockReturnValue({ getSpaceId: jest.fn(), namespaceToSpaceId: jest.fn() }),
getCurrentUser: jest.fn(),
customBranding: mockCoreSetup.customBranding,
});
const featuresStart = featuresPluginMock.createStart();

View file

@ -12,6 +12,7 @@ import type { Observable, Subscription } from 'rxjs';
import type {
CapabilitiesSetup,
CustomBrandingSetup,
HttpServiceSetup,
IClusterClient,
KibanaRequest,
@ -64,6 +65,7 @@ interface AuthorizationServiceSetupParams {
kibanaIndexName: string;
getSpacesService(): SpacesService | undefined;
getCurrentUser(request: KibanaRequest): AuthenticatedUser | null;
customBranding: CustomBrandingSetup;
}
interface AuthorizationServiceStartParams {
@ -117,6 +119,7 @@ export class AuthorizationService {
kibanaIndexName,
getSpacesService,
getCurrentUser,
customBranding,
}: AuthorizationServiceSetupParams): AuthorizationServiceSetupInternal {
this.logger = loggers.get('authorization');
this.applicationName = `${APPLICATION_PREFIX}${kibanaIndexName}`;
@ -176,8 +179,11 @@ export class AuthorizationService {
initAPIAuthorization(http, authz, loggers.get('api-authorization'));
initAppAuthorization(http, authz, loggers.get('app-authorization'), features);
http.registerOnPreResponse((request, preResponse, toolkit) => {
http.registerOnPreResponse(async (request, preResponse, toolkit) => {
if (preResponse.statusCode === 403 && canRedirectRequest(request)) {
const customBrandingValue = await customBranding.getBrandingFor(request, {
unauthenticated: false,
});
const next = `${http.basePath.get(request)}${request.url.pathname}${request.url.search}`;
const body = renderToString(
<ResetSessionPage
@ -186,6 +192,7 @@ export class AuthorizationService {
logoutUrl={http.basePath.prepend(
`/api/security/logout?${querystring.stringify({ next })}`
)}
customBranding={customBrandingValue}
/>
);

View file

@ -28,6 +28,25 @@ describe('ResetSessionPage', () => {
logoutUrl="/path/to/logout"
buildNumber={100500}
basePath={mockCoreSetup.http.basePath}
customBranding={{}}
/>
);
expect(body).toMatchSnapshot();
});
it('renders as expected with custom page title', async () => {
const mockCoreSetup = coreMock.createSetup();
(mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation(
(path) => `/mock-basepath${path}`
);
const body = renderToStaticMarkup(
<ResetSessionPage
logoutUrl="/path/to/logout"
buildNumber={100500}
basePath={mockCoreSetup.http.basePath}
customBranding={{ pageTitle: 'My Company Name' }}
/>
);

View file

@ -9,6 +9,7 @@
import { EuiButton, EuiButtonEmpty } from '@elastic/eui/lib/components/button';
import React from 'react';
import type { CustomBranding } from '@kbn/core-custom-branding-common';
import type { IBasePath } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
@ -19,10 +20,12 @@ export function ResetSessionPage({
logoutUrl,
buildNumber,
basePath,
customBranding,
}: {
logoutUrl: string;
buildNumber: number;
basePath: IBasePath;
customBranding: CustomBranding;
}) {
return (
<PromptPage
@ -54,6 +57,7 @@ export function ResetSessionPage({
/>
</EuiButtonEmpty>,
]}
customBranding={customBranding}
/>
);
}

View file

@ -267,6 +267,7 @@ export class SecurityPlugin
config,
license,
buildNumber: this.initializerContext.env.packageInfo.buildNum,
customBranding: core.customBranding,
});
registerSecurityUsageCollector({ usageCollection, config, license });
@ -297,6 +298,7 @@ export class SecurityPlugin
getSpacesService: () => spaces?.spacesService,
features,
getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request),
customBranding: core.customBranding,
});
this.userProfileService.setup({ authz: this.authorizationSetup, license });

View file

@ -29,6 +29,7 @@ describe('PromptPage', () => {
basePath={mockCoreSetup.http.basePath}
title="Some Title"
body={<div>Some Body</div>}
customBranding={{}}
actions={[<span>Action#1</span>, <span>Action#2</span>]}
/>
);
@ -49,6 +50,7 @@ describe('PromptPage', () => {
scriptPaths={['/some/script1.js', '/some/script2.js']}
title="Some Title"
body={<div>Some Body</div>}
customBranding={{}}
actions={[<span>Action#1</span>, <span>Action#2</span>]}
/>
);

View file

@ -22,6 +22,7 @@ import type { ReactNode } from 'react';
import React from 'react';
import { renderToString } from 'react-dom/server';
import type { CustomBranding } from '@kbn/core-custom-branding-common';
import { Fonts } from '@kbn/core-rendering-server-internal';
import type { IBasePath } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
@ -45,6 +46,7 @@ interface Props {
title: ReactNode;
body: ReactNode;
actions: ReactNode;
customBranding: CustomBranding;
}
export function PromptPage({
@ -54,6 +56,7 @@ export function PromptPage({
title,
body,
actions,
customBranding,
}: Props) {
const content = (
<I18nProvider>
@ -92,7 +95,7 @@ export function PromptPage({
return (
<html lang={i18n.getLocale()}>
<head>
<title>Elastic</title>
<title>{customBranding.pageTitle ? customBranding.pageTitle : 'Elastic'}</title>
{/* eslint-disable-next-line react/no-danger */}
<style dangerouslySetInnerHTML={{ __html: `</style>${emotionStyles}` }} />
{styleSheetPaths.map((path) => (
@ -100,8 +103,20 @@ export function PromptPage({
))}
<Fonts url={uiPublicURL} />
{/* The alternate icon is a fallback for Safari which does not yet support SVG favicons */}
<link rel="alternate icon" type="image/png" href={`${uiPublicURL}/favicons/favicon.png`} />
<link rel="icon" type="image/svg+xml" href={`${uiPublicURL}/favicons/favicon.svg`} />
{customBranding.faviconPNG ? (
<link rel="alternate icon" type="image/png" href={customBranding.faviconPNG} />
) : (
<link
rel="alternate icon"
type="image/png"
href={`${uiPublicURL}/favicons/favicon.png`}
/>
)}
{customBranding.faviconSVG ? (
<link rel="icon" type="image/svg+xml" href={customBranding.faviconSVG} />
) : (
<link rel="icon" type="image/svg+xml" href={`${uiPublicURL}/favicons/favicon.svg`} />
)}
{scriptPaths.map((path) => (
<script src={basePath.prepend(path)} key={path} />
))}

View file

@ -49,6 +49,9 @@
"@kbn/logging-mocks",
"@kbn/web-worker-stub",
"@kbn/core-saved-objects-utils-server",
"@kbn/core-custom-branding-browser-mocks",
"@kbn/core-custom-branding-common",
"@kbn/core-custom-branding-server-mocks",
],
"exclude": [
"target/**/*",