Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -35693,7 +35693,6 @@
"xpack.security.roleMappings.createBreadcrumb": "Créer",
"xpack.security.roles.createBreadcrumb": "Créer",
"xpack.security.sessionExpirationToast.body": "Vous serez déconnecté {timeout}.",
"xpack.security.sessionExpirationToast.extendButton": "Rester connecté",
"xpack.security.sessionExpirationToast.title": "Délai d'expiration de session",
"xpack.security.uiApi.errorBoundaryToastMessage": "Rechargez la page pour continuer.",
"xpack.security.uiApi.errorBoundaryToastTitle": "Impossible de charger la ressource Kibana",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35669,7 +35669,6 @@
"xpack.security.roleMappings.createBreadcrumb": "作成",
"xpack.security.roles.createBreadcrumb": "作成",
"xpack.security.sessionExpirationToast.body": "ログアウト{timeout}します。",
"xpack.security.sessionExpirationToast.extendButton": "ログイン状態を維持",
"xpack.security.sessionExpirationToast.title": "セッションタイムアウト",
"xpack.security.uiApi.errorBoundaryToastMessage": "続行するにはページを再読み込みしてください。",
"xpack.security.uiApi.errorBoundaryToastTitle": "Kibanaアセットを読み込めませんでした",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35727,7 +35727,6 @@
"xpack.security.roleMappings.createBreadcrumb": "创建",
"xpack.security.roles.createBreadcrumb": "创建",
"xpack.security.sessionExpirationToast.body": "您会在 {timeout} 后自动注销。",
"xpack.security.sessionExpirationToast.extendButton": "保持登录",
"xpack.security.sessionExpirationToast.title": "会话超时",
"xpack.security.uiApi.errorBoundaryToastMessage": "重新加载页面以继续。",
"xpack.security.uiApi.errorBoundaryToastTitle": "无法加载 Kibana 资产",
Expand Down
11 changes: 9 additions & 2 deletions x-pack/platform/plugins/shared/security/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,15 +192,22 @@ export class SecurityPlugin
core: CoreStart,
{ management, share }: PluginStartDependencies
): SecurityPluginStart {
const { application, http, notifications } = core;
const { application, http, notifications, overlays } = core;
const { anonymousPaths } = http;

const logoutUrl = getLogoutUrl(http);
const tenant = http.basePath.serverBasePath;

const sessionExpired = new SessionExpired(application, logoutUrl, tenant);
http.intercept(new UnauthorizedResponseHttpInterceptor(sessionExpired, anonymousPaths));
this.sessionTimeout = new SessionTimeout(core, notifications, sessionExpired, http, tenant);
this.sessionTimeout = new SessionTimeout(
core,
notifications,
overlays,
sessionExpired,
http,
tenant
);

this.sessionTimeout.start();
this.securityCheckupService.start(core);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { render } from '@testing-library/react';
import React from 'react';
import { of } from 'rxjs';

import { I18nProvider } from '@kbn/i18n-react';

import { SessionExpirationModal } from './session_expiration_modal';
import type { SessionState } from './session_timeout';

describe('SessionExpirationModal', () => {
it('renders modal when session state is available', () => {
const sessionState$ = of<SessionState>({
lastExtensionTime: Date.now(),
expiresInMs: 60 * 1000,
canBeExtended: true,
});
const onExtend = jest.fn();
const onClose = jest.fn();

const { getByTestId } = render(
<I18nProvider>
<SessionExpirationModal
sessionState$={sessionState$}
onExtend={onExtend}
onClose={onClose}
/>
</I18nProvider>
);

const extendButton = getByTestId('session-expiration-extend-button');

expect(extendButton).toHaveTextContent('Stay logged in');
expect(extendButton).toHaveFocus();
});

it('renders null when session state is not available', () => {
const sessionState$ = of(null);
const onExtend = jest.fn();
const onClose = jest.fn();

const { container } = render(
<I18nProvider>
<SessionExpirationModal
// @ts-expect-error - we want to test the null case
sessionState$={sessionState$}
onExtend={onExtend}
onClose={onClose}
/>
</I18nProvider>
);

expect(container.firstChild).toBeNull();
});

it('renders null when expiresInMs is not available', () => {
const sessionState$ = of<SessionState>({
lastExtensionTime: Date.now(),
expiresInMs: null,
canBeExtended: true,
});
const onExtend = jest.fn();
const onClose = jest.fn();

const { container } = render(
<I18nProvider>
<SessionExpirationModal
sessionState$={sessionState$}
onExtend={onExtend}
onClose={onClose}
/>
</I18nProvider>
);

expect(container.firstChild).toBeNull();
});

it('has proper accessibility attributes', () => {
const sessionState$ = of<SessionState>({
lastExtensionTime: Date.now(),
expiresInMs: 60 * 1000,
canBeExtended: true,
});
const onExtend = jest.fn();
const onClose = jest.fn();

const { queryByRole, getByText } = render(
<I18nProvider>
<SessionExpirationModal
sessionState$={sessionState$}
onExtend={onExtend}
onClose={onClose}
/>
</I18nProvider>
);

const modal = queryByRole('dialog');
expect(modal).toHaveAttribute('aria-labelledby', 'session-expiration-modal-title');
expect(getByText('Session timeout')).toHaveAttribute('id', 'session-expiration-modal-title');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import {
EuiButton,
EuiFlexGroup,
EuiIcon,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
} from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import useObservable from 'react-use/lib/useObservable';
import type { Observable } from 'rxjs';

import { i18n } from '@kbn/i18n';
import { FormattedMessage, FormattedRelativeTime } from '@kbn/i18n-react';
import { toMountPoint } from '@kbn/react-kibana-mount';

import type { SessionState } from './session_timeout';
import type { StartServices } from '..';
import { SESSION_GRACE_PERIOD_MS } from '../../common/constants';

export interface SessionExpirationModalProps {
sessionState$: Observable<SessionState>;
onExtend: () => Promise<any>;
onClose: () => void;
}

export const SessionExpirationModal: FunctionComponent<SessionExpirationModalProps> = ({
sessionState$,
onExtend,
onClose,
}) => {
const state = useObservable(sessionState$);
const [{ loading }, extend] = useAsyncFn(onExtend);

if (!state || !state.expiresInMs) {
return null;
}

const timeoutSeconds = Math.max(state.expiresInMs - SESSION_GRACE_PERIOD_MS, 0) / 1000;

return (
<EuiModal
onClose={onClose}
initialFocus="[data-test-subj=session-expiration-extend-button]"
role="dialog"
aria-labelledby="session-expiration-modal-title"
>
<EuiModalHeader>
<EuiModalHeaderTitle id="session-expiration-modal-title">
<EuiIcon type="clock" color="warning" size="l" style={{ marginRight: 8 }} />
{i18n.translate('xpack.security.sessionExpirationModal.title', {
defaultMessage: 'Session timeout',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>

<EuiModalBody>
<FormattedMessage
id="xpack.security.sessionExpirationModal.body"
defaultMessage="You will be logged out {timeout}. Please save your work and log in again."
values={{
timeout: <FormattedRelativeTime value={timeoutSeconds} updateIntervalInSeconds={1} />,
}}
/>
</EuiModalBody>

<EuiModalFooter>
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiButton
color="primary"
isLoading={loading}
onClick={extend}
data-test-subj="session-expiration-extend-button"
autoFocus
>
<FormattedMessage
id="xpack.security.sessionExpirationModal.extendButton"
defaultMessage="Stay logged in"
/>
</EuiButton>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
);
};

export const createSessionExpirationModal = (
services: StartServices,
sessionState$: Observable<SessionState>,
onExtend: () => Promise<any>,
onClose: () => void
) => {
return toMountPoint(
<SessionExpirationModal sessionState$={sessionState$} onExtend={onExtend} onClose={onClose} />,
services
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { fireEvent, render } from '@testing-library/react';
import { render } from '@testing-library/react';
import React from 'react';
import { of } from 'rxjs';

Expand All @@ -22,9 +22,8 @@ describe('createSessionExpirationToast', () => {
expiresInMs: 60 * 1000,
canBeExtended: true,
});
const onExtend = jest.fn();
const onClose = jest.fn();
const toast = createSessionExpirationToast(coreStart, sessionState$, onExtend, onClose);
const toast = createSessionExpirationToast(coreStart, sessionState$, onClose);

expect(toast).toEqual(
expect.objectContaining({
Expand All @@ -49,7 +48,7 @@ describe('SessionExpirationToast', () => {

const { getByText } = render(
<I18nProvider>
<SessionExpirationToast sessionState$={sessionState$} onExtend={jest.fn()} />
<SessionExpirationToast sessionState$={sessionState$} />
</I18nProvider>
);
getByText(/You will be logged out in [0-9]+ minutes/);
Expand All @@ -64,41 +63,22 @@ describe('SessionExpirationToast', () => {

const { getByText } = render(
<I18nProvider>
<SessionExpirationToast sessionState$={sessionState$} onExtend={jest.fn()} />
<SessionExpirationToast sessionState$={sessionState$} />
</I18nProvider>
);
getByText(/You will be logged out in [0-9]+ seconds/);
});

it('renders extend button if session can be extended', () => {
const sessionState$ = of<SessionState>({
lastExtensionTime: Date.now(),
expiresInMs: 60 * 1000,
canBeExtended: true,
});
const onExtend = jest.fn().mockReturnValue(new Promise(() => {}));

const { getByRole } = render(
<I18nProvider>
<SessionExpirationToast sessionState$={sessionState$} onExtend={onExtend} />
</I18nProvider>
);
fireEvent.click(getByRole('button', { name: 'Stay logged in' }));

expect(onExtend).toHaveBeenCalled();
});

it('does not render extend button if session cannot be extended', () => {
const sessionState$ = of<SessionState>({
lastExtensionTime: Date.now(),
expiresInMs: 60 * 1000,
canBeExtended: false,
});
const onExtend = jest.fn();

const { queryByRole } = render(
<I18nProvider>
<SessionExpirationToast sessionState$={sessionState$} onExtend={onExtend} />
<SessionExpirationToast sessionState$={sessionState$} />
</I18nProvider>
);
expect(queryByRole('button', { name: 'Stay logged in' })).toBeNull();
Expand Down
Loading