Skip to content

Commit 45c9471

Browse files
[9.0] [a11y] Changed session timeout toast to modal (elastic#235957) (elastic#236890)
# Backport This will backport the following commits from `main` to `9.0`: - [[a11y] Changed session timeout toast to modal (elastic#235957)](elastic#235957) <!--- Backport version: 10.0.2 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Elena Shostak","email":"[email protected]"},"sourceCommit":{"committedDate":"2025-09-29T21:15:35Z","message":"[a11y] Changed session timeout toast to modal (elastic#235957)\n\n## Summary\n\nChanged session timeout toast to session timeout modal.\n\n\nBefore\n<img width=\"599\" height=\"381\" alt=\"Screenshot 2025-09-25 at 15 24 58\"\nsrc=\"https://github.com/user-attachments/assets/d78023cc-1797-4fdd-8c16-07fa468dab76\"\n/>\n\n\nAfter\n<img width=\"843\" height=\"626\" alt=\"Screenshot 2025-09-25 at 17 43 00\"\nsrc=\"https://github.com/user-attachments/assets/8fba6545-af6e-45c7-93c9-b5be3c05125e\"\n/>\n\n\n### Checklist\n\n- [x] The PR description includes the appropriate Release Notes section,\nand the correct `release_note:*` label is applied per the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n- [x] Review the [backport\nguidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing)\nand apply applicable `backport:*` labels.\n\n__Closes: https://github.com/elastic/kibana/issues/138333__\n\n---------\n\nCo-authored-by: kibanamachine <[email protected]>","sha":"75d4b7686872652368fe7d4afb0c5a4295631ec7","branchLabelMapping":{"^v9.2.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Security","enhancement","release_note:skip","Feature:Security/Session Management","backport:version","a11y","v9.2.0","v8.18.8","v8.19.5","v9.0.8","v9.1.5"],"title":"[a11y] Changed session timeout toast to modal","number":235957,"url":"https://github.com/elastic/kibana/pull/235957","mergeCommit":{"message":"[a11y] Changed session timeout toast to modal (elastic#235957)\n\n## Summary\n\nChanged session timeout toast to session timeout modal.\n\n\nBefore\n<img width=\"599\" height=\"381\" alt=\"Screenshot 2025-09-25 at 15 24 58\"\nsrc=\"https://github.com/user-attachments/assets/d78023cc-1797-4fdd-8c16-07fa468dab76\"\n/>\n\n\nAfter\n<img width=\"843\" height=\"626\" alt=\"Screenshot 2025-09-25 at 17 43 00\"\nsrc=\"https://github.com/user-attachments/assets/8fba6545-af6e-45c7-93c9-b5be3c05125e\"\n/>\n\n\n### Checklist\n\n- [x] The PR description includes the appropriate Release Notes section,\nand the correct `release_note:*` label is applied per the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n- [x] Review the [backport\nguidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing)\nand apply applicable `backport:*` labels.\n\n__Closes: https://github.com/elastic/kibana/issues/138333__\n\n---------\n\nCo-authored-by: kibanamachine <[email protected]>","sha":"75d4b7686872652368fe7d4afb0c5a4295631ec7"}},"sourceBranch":"main","suggestedTargetBranches":["8.18","8.19","9.0","9.1"],"targetPullRequestStates":[{"branch":"main","label":"v9.2.0","branchLabelMappingKey":"^v9.2.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/235957","number":235957,"mergeCommit":{"message":"[a11y] Changed session timeout toast to modal (elastic#235957)\n\n## Summary\n\nChanged session timeout toast to session timeout modal.\n\n\nBefore\n<img width=\"599\" height=\"381\" alt=\"Screenshot 2025-09-25 at 15 24 58\"\nsrc=\"https://github.com/user-attachments/assets/d78023cc-1797-4fdd-8c16-07fa468dab76\"\n/>\n\n\nAfter\n<img width=\"843\" height=\"626\" alt=\"Screenshot 2025-09-25 at 17 43 00\"\nsrc=\"https://github.com/user-attachments/assets/8fba6545-af6e-45c7-93c9-b5be3c05125e\"\n/>\n\n\n### Checklist\n\n- [x] The PR description includes the appropriate Release Notes section,\nand the correct `release_note:*` label is applied per the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n- [x] Review the [backport\nguidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing)\nand apply applicable `backport:*` labels.\n\n__Closes: https://github.com/elastic/kibana/issues/138333__\n\n---------\n\nCo-authored-by: kibanamachine <[email protected]>","sha":"75d4b7686872652368fe7d4afb0c5a4295631ec7"}},{"branch":"8.18","label":"v8.18.8","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.19","label":"v8.19.5","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"9.0","label":"v9.0.8","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"9.1","label":"v9.1.5","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT-->
1 parent 144d9d4 commit 45c9471

File tree

10 files changed

+292
-104
lines changed

10 files changed

+292
-104
lines changed

x-pack/platform/plugins/private/translations/translations/fr-FR.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35693,7 +35693,6 @@
3569335693
"xpack.security.roleMappings.createBreadcrumb": "Créer",
3569435694
"xpack.security.roles.createBreadcrumb": "Créer",
3569535695
"xpack.security.sessionExpirationToast.body": "Vous serez déconnecté {timeout}.",
35696-
"xpack.security.sessionExpirationToast.extendButton": "Rester connecté",
3569735696
"xpack.security.sessionExpirationToast.title": "Délai d'expiration de session",
3569835697
"xpack.security.uiApi.errorBoundaryToastMessage": "Rechargez la page pour continuer.",
3569935698
"xpack.security.uiApi.errorBoundaryToastTitle": "Impossible de charger la ressource Kibana",

x-pack/platform/plugins/private/translations/translations/ja-JP.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35669,7 +35669,6 @@
3566935669
"xpack.security.roleMappings.createBreadcrumb": "作成",
3567035670
"xpack.security.roles.createBreadcrumb": "作成",
3567135671
"xpack.security.sessionExpirationToast.body": "ログアウト{timeout}します。",
35672-
"xpack.security.sessionExpirationToast.extendButton": "ログイン状態を維持",
3567335672
"xpack.security.sessionExpirationToast.title": "セッションタイムアウト",
3567435673
"xpack.security.uiApi.errorBoundaryToastMessage": "続行するにはページを再読み込みしてください。",
3567535674
"xpack.security.uiApi.errorBoundaryToastTitle": "Kibanaアセットを読み込めませんでした",

x-pack/platform/plugins/private/translations/translations/zh-CN.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35727,7 +35727,6 @@
3572735727
"xpack.security.roleMappings.createBreadcrumb": "创建",
3572835728
"xpack.security.roles.createBreadcrumb": "创建",
3572935729
"xpack.security.sessionExpirationToast.body": "您会在 {timeout} 后自动注销。",
35730-
"xpack.security.sessionExpirationToast.extendButton": "保持登录",
3573135730
"xpack.security.sessionExpirationToast.title": "会话超时",
3573235731
"xpack.security.uiApi.errorBoundaryToastMessage": "重新加载页面以继续。",
3573335732
"xpack.security.uiApi.errorBoundaryToastTitle": "无法加载 Kibana 资产",

x-pack/platform/plugins/shared/security/public/plugin.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,15 +192,22 @@ export class SecurityPlugin
192192
core: CoreStart,
193193
{ management, share }: PluginStartDependencies
194194
): SecurityPluginStart {
195-
const { application, http, notifications } = core;
195+
const { application, http, notifications, overlays } = core;
196196
const { anonymousPaths } = http;
197197

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

201201
const sessionExpired = new SessionExpired(application, logoutUrl, tenant);
202202
http.intercept(new UnauthorizedResponseHttpInterceptor(sessionExpired, anonymousPaths));
203-
this.sessionTimeout = new SessionTimeout(core, notifications, sessionExpired, http, tenant);
203+
this.sessionTimeout = new SessionTimeout(
204+
core,
205+
notifications,
206+
overlays,
207+
sessionExpired,
208+
http,
209+
tenant
210+
);
204211

205212
this.sessionTimeout.start();
206213
this.securityCheckupService.start(core);
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { render } from '@testing-library/react';
9+
import React from 'react';
10+
import { of } from 'rxjs';
11+
12+
import { I18nProvider } from '@kbn/i18n-react';
13+
14+
import { SessionExpirationModal } from './session_expiration_modal';
15+
import type { SessionState } from './session_timeout';
16+
17+
describe('SessionExpirationModal', () => {
18+
it('renders modal when session state is available', () => {
19+
const sessionState$ = of<SessionState>({
20+
lastExtensionTime: Date.now(),
21+
expiresInMs: 60 * 1000,
22+
canBeExtended: true,
23+
});
24+
const onExtend = jest.fn();
25+
const onClose = jest.fn();
26+
27+
const { getByTestId } = render(
28+
<I18nProvider>
29+
<SessionExpirationModal
30+
sessionState$={sessionState$}
31+
onExtend={onExtend}
32+
onClose={onClose}
33+
/>
34+
</I18nProvider>
35+
);
36+
37+
const extendButton = getByTestId('session-expiration-extend-button');
38+
39+
expect(extendButton).toHaveTextContent('Stay logged in');
40+
expect(extendButton).toHaveFocus();
41+
});
42+
43+
it('renders null when session state is not available', () => {
44+
const sessionState$ = of(null);
45+
const onExtend = jest.fn();
46+
const onClose = jest.fn();
47+
48+
const { container } = render(
49+
<I18nProvider>
50+
<SessionExpirationModal
51+
// @ts-expect-error - we want to test the null case
52+
sessionState$={sessionState$}
53+
onExtend={onExtend}
54+
onClose={onClose}
55+
/>
56+
</I18nProvider>
57+
);
58+
59+
expect(container.firstChild).toBeNull();
60+
});
61+
62+
it('renders null when expiresInMs is not available', () => {
63+
const sessionState$ = of<SessionState>({
64+
lastExtensionTime: Date.now(),
65+
expiresInMs: null,
66+
canBeExtended: true,
67+
});
68+
const onExtend = jest.fn();
69+
const onClose = jest.fn();
70+
71+
const { container } = render(
72+
<I18nProvider>
73+
<SessionExpirationModal
74+
sessionState$={sessionState$}
75+
onExtend={onExtend}
76+
onClose={onClose}
77+
/>
78+
</I18nProvider>
79+
);
80+
81+
expect(container.firstChild).toBeNull();
82+
});
83+
84+
it('has proper accessibility attributes', () => {
85+
const sessionState$ = of<SessionState>({
86+
lastExtensionTime: Date.now(),
87+
expiresInMs: 60 * 1000,
88+
canBeExtended: true,
89+
});
90+
const onExtend = jest.fn();
91+
const onClose = jest.fn();
92+
93+
const { queryByRole, getByText } = render(
94+
<I18nProvider>
95+
<SessionExpirationModal
96+
sessionState$={sessionState$}
97+
onExtend={onExtend}
98+
onClose={onClose}
99+
/>
100+
</I18nProvider>
101+
);
102+
103+
const modal = queryByRole('dialog');
104+
expect(modal).toHaveAttribute('aria-labelledby', 'session-expiration-modal-title');
105+
expect(getByText('Session timeout')).toHaveAttribute('id', 'session-expiration-modal-title');
106+
});
107+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import {
9+
EuiButton,
10+
EuiFlexGroup,
11+
EuiIcon,
12+
EuiModal,
13+
EuiModalBody,
14+
EuiModalFooter,
15+
EuiModalHeader,
16+
EuiModalHeaderTitle,
17+
} from '@elastic/eui';
18+
import type { FunctionComponent } from 'react';
19+
import React from 'react';
20+
import useAsyncFn from 'react-use/lib/useAsyncFn';
21+
import useObservable from 'react-use/lib/useObservable';
22+
import type { Observable } from 'rxjs';
23+
24+
import { i18n } from '@kbn/i18n';
25+
import { FormattedMessage, FormattedRelativeTime } from '@kbn/i18n-react';
26+
import { toMountPoint } from '@kbn/react-kibana-mount';
27+
28+
import type { SessionState } from './session_timeout';
29+
import type { StartServices } from '..';
30+
import { SESSION_GRACE_PERIOD_MS } from '../../common/constants';
31+
32+
export interface SessionExpirationModalProps {
33+
sessionState$: Observable<SessionState>;
34+
onExtend: () => Promise<any>;
35+
onClose: () => void;
36+
}
37+
38+
export const SessionExpirationModal: FunctionComponent<SessionExpirationModalProps> = ({
39+
sessionState$,
40+
onExtend,
41+
onClose,
42+
}) => {
43+
const state = useObservable(sessionState$);
44+
const [{ loading }, extend] = useAsyncFn(onExtend);
45+
46+
if (!state || !state.expiresInMs) {
47+
return null;
48+
}
49+
50+
const timeoutSeconds = Math.max(state.expiresInMs - SESSION_GRACE_PERIOD_MS, 0) / 1000;
51+
52+
return (
53+
<EuiModal
54+
onClose={onClose}
55+
initialFocus="[data-test-subj=session-expiration-extend-button]"
56+
role="dialog"
57+
aria-labelledby="session-expiration-modal-title"
58+
>
59+
<EuiModalHeader>
60+
<EuiModalHeaderTitle id="session-expiration-modal-title">
61+
<EuiIcon type="clock" color="warning" size="l" style={{ marginRight: 8 }} />
62+
{i18n.translate('xpack.security.sessionExpirationModal.title', {
63+
defaultMessage: 'Session timeout',
64+
})}
65+
</EuiModalHeaderTitle>
66+
</EuiModalHeader>
67+
68+
<EuiModalBody>
69+
<FormattedMessage
70+
id="xpack.security.sessionExpirationModal.body"
71+
defaultMessage="You will be logged out {timeout}. Please save your work and log in again."
72+
values={{
73+
timeout: <FormattedRelativeTime value={timeoutSeconds} updateIntervalInSeconds={1} />,
74+
}}
75+
/>
76+
</EuiModalBody>
77+
78+
<EuiModalFooter>
79+
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
80+
<EuiButton
81+
color="primary"
82+
isLoading={loading}
83+
onClick={extend}
84+
data-test-subj="session-expiration-extend-button"
85+
autoFocus
86+
>
87+
<FormattedMessage
88+
id="xpack.security.sessionExpirationModal.extendButton"
89+
defaultMessage="Stay logged in"
90+
/>
91+
</EuiButton>
92+
</EuiFlexGroup>
93+
</EuiModalFooter>
94+
</EuiModal>
95+
);
96+
};
97+
98+
export const createSessionExpirationModal = (
99+
services: StartServices,
100+
sessionState$: Observable<SessionState>,
101+
onExtend: () => Promise<any>,
102+
onClose: () => void
103+
) => {
104+
return toMountPoint(
105+
<SessionExpirationModal sessionState$={sessionState$} onExtend={onExtend} onClose={onClose} />,
106+
services
107+
);
108+
};

x-pack/platform/plugins/shared/security/public/session/session_expiration_toast.test.tsx

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* 2.0; you may not use this file except in compliance with the Elastic License
55
* 2.0.
66
*/
7-
import { fireEvent, render } from '@testing-library/react';
7+
import { render } from '@testing-library/react';
88
import React from 'react';
99
import { of } from 'rxjs';
1010

@@ -22,9 +22,8 @@ describe('createSessionExpirationToast', () => {
2222
expiresInMs: 60 * 1000,
2323
canBeExtended: true,
2424
});
25-
const onExtend = jest.fn();
2625
const onClose = jest.fn();
27-
const toast = createSessionExpirationToast(coreStart, sessionState$, onExtend, onClose);
26+
const toast = createSessionExpirationToast(coreStart, sessionState$, onClose);
2827

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

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

6564
const { getByText } = render(
6665
<I18nProvider>
67-
<SessionExpirationToast sessionState$={sessionState$} onExtend={jest.fn()} />
66+
<SessionExpirationToast sessionState$={sessionState$} />
6867
</I18nProvider>
6968
);
7069
getByText(/You will be logged out in [0-9]+ seconds/);
7170
});
7271

73-
it('renders extend button if session can be extended', () => {
74-
const sessionState$ = of<SessionState>({
75-
lastExtensionTime: Date.now(),
76-
expiresInMs: 60 * 1000,
77-
canBeExtended: true,
78-
});
79-
const onExtend = jest.fn().mockReturnValue(new Promise(() => {}));
80-
81-
const { getByRole } = render(
82-
<I18nProvider>
83-
<SessionExpirationToast sessionState$={sessionState$} onExtend={onExtend} />
84-
</I18nProvider>
85-
);
86-
fireEvent.click(getByRole('button', { name: 'Stay logged in' }));
87-
88-
expect(onExtend).toHaveBeenCalled();
89-
});
90-
9172
it('does not render extend button if session cannot be extended', () => {
9273
const sessionState$ = of<SessionState>({
9374
lastExtensionTime: Date.now(),
9475
expiresInMs: 60 * 1000,
9576
canBeExtended: false,
9677
});
97-
const onExtend = jest.fn();
9878

9979
const { queryByRole } = render(
10080
<I18nProvider>
101-
<SessionExpirationToast sessionState$={sessionState$} onExtend={onExtend} />
81+
<SessionExpirationToast sessionState$={sessionState$} />
10282
</I18nProvider>
10383
);
10484
expect(queryByRole('button', { name: 'Stay logged in' })).toBeNull();

0 commit comments

Comments
 (0)