From 4646330f3f6fcc44af171ecb3fafc954ddfa806c Mon Sep 17 00:00:00 2001 From: Elena Shostak <165678770+elena-shostak@users.noreply.github.com> Date: Mon, 29 Sep 2025 23:15:35 +0200 Subject: [PATCH] [a11y] Changed session timeout toast to modal (#235957) ## Summary Changed session timeout toast to session timeout modal. Before Screenshot 2025-09-25 at 15 24 58 After Screenshot 2025-09-25 at 17 43 00 ### Checklist - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [x] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. __Closes: https://github.com/elastic/kibana/issues/138333__ --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> (cherry picked from commit 75d4b7686872652368fe7d4afb0c5a4295631ec7) # Conflicts: # x-pack/platform/plugins/private/translations/translations/de-DE.json # x-pack/platform/plugins/shared/security/public/session/session_timeout.test.ts # x-pack/platform/plugins/shared/security/public/session/session_timeout.ts --- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../plugins/shared/security/public/plugin.tsx | 11 +- .../session/session_expiration_modal.test.tsx | 107 +++++++++++++++++ .../session/session_expiration_modal.tsx | 108 ++++++++++++++++++ .../session/session_expiration_toast.test.tsx | 30 +---- .../session/session_expiration_toast.tsx | 32 +----- .../public/session/session_timeout.test.ts | 51 +++++---- .../public/session/session_timeout.ts | 54 +++++---- 10 files changed, 292 insertions(+), 104 deletions(-) create mode 100644 x-pack/platform/plugins/shared/security/public/session/session_expiration_modal.test.tsx create mode 100644 x-pack/platform/plugins/shared/security/public/session/session_expiration_modal.tsx diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index be079c80fe35d..26177be39dd1a 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -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", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index f4bebf65b9f42..cab56768cc08d 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -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アセットを読み込めませんでした", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index eff87830f9c9b..a096e1906ca45 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -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 资产", diff --git a/x-pack/platform/plugins/shared/security/public/plugin.tsx b/x-pack/platform/plugins/shared/security/public/plugin.tsx index beb1015f8bcee..58553f8765ec9 100644 --- a/x-pack/platform/plugins/shared/security/public/plugin.tsx +++ b/x-pack/platform/plugins/shared/security/public/plugin.tsx @@ -192,7 +192,7 @@ 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); @@ -200,7 +200,14 @@ export class SecurityPlugin 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); diff --git a/x-pack/platform/plugins/shared/security/public/session/session_expiration_modal.test.tsx b/x-pack/platform/plugins/shared/security/public/session/session_expiration_modal.test.tsx new file mode 100644 index 0000000000000..944dde7f46166 --- /dev/null +++ b/x-pack/platform/plugins/shared/security/public/session/session_expiration_modal.test.tsx @@ -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({ + lastExtensionTime: Date.now(), + expiresInMs: 60 * 1000, + canBeExtended: true, + }); + const onExtend = jest.fn(); + const onClose = jest.fn(); + + const { getByTestId } = render( + + + + ); + + 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( + + + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('renders null when expiresInMs is not available', () => { + const sessionState$ = of({ + lastExtensionTime: Date.now(), + expiresInMs: null, + canBeExtended: true, + }); + const onExtend = jest.fn(); + const onClose = jest.fn(); + + const { container } = render( + + + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('has proper accessibility attributes', () => { + const sessionState$ = of({ + lastExtensionTime: Date.now(), + expiresInMs: 60 * 1000, + canBeExtended: true, + }); + const onExtend = jest.fn(); + const onClose = jest.fn(); + + const { queryByRole, getByText } = render( + + + + ); + + const modal = queryByRole('dialog'); + expect(modal).toHaveAttribute('aria-labelledby', 'session-expiration-modal-title'); + expect(getByText('Session timeout')).toHaveAttribute('id', 'session-expiration-modal-title'); + }); +}); diff --git a/x-pack/platform/plugins/shared/security/public/session/session_expiration_modal.tsx b/x-pack/platform/plugins/shared/security/public/session/session_expiration_modal.tsx new file mode 100644 index 0000000000000..8c13059c58a48 --- /dev/null +++ b/x-pack/platform/plugins/shared/security/public/session/session_expiration_modal.tsx @@ -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; + onExtend: () => Promise; + onClose: () => void; +} + +export const SessionExpirationModal: FunctionComponent = ({ + 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 ( + + + + + {i18n.translate('xpack.security.sessionExpirationModal.title', { + defaultMessage: 'Session timeout', + })} + + + + + , + }} + /> + + + + + + + + + + + ); +}; + +export const createSessionExpirationModal = ( + services: StartServices, + sessionState$: Observable, + onExtend: () => Promise, + onClose: () => void +) => { + return toMountPoint( + , + services + ); +}; diff --git a/x-pack/platform/plugins/shared/security/public/session/session_expiration_toast.test.tsx b/x-pack/platform/plugins/shared/security/public/session/session_expiration_toast.test.tsx index 46b733c535ec4..03224e6ab3902 100644 --- a/x-pack/platform/plugins/shared/security/public/session/session_expiration_toast.test.tsx +++ b/x-pack/platform/plugins/shared/security/public/session/session_expiration_toast.test.tsx @@ -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'; @@ -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({ @@ -49,7 +48,7 @@ describe('SessionExpirationToast', () => { const { getByText } = render( - + ); getByText(/You will be logged out in [0-9]+ minutes/); @@ -64,41 +63,22 @@ describe('SessionExpirationToast', () => { const { getByText } = render( - + ); getByText(/You will be logged out in [0-9]+ seconds/); }); - it('renders extend button if session can be extended', () => { - const sessionState$ = of({ - lastExtensionTime: Date.now(), - expiresInMs: 60 * 1000, - canBeExtended: true, - }); - const onExtend = jest.fn().mockReturnValue(new Promise(() => {})); - - const { getByRole } = render( - - - - ); - 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({ lastExtensionTime: Date.now(), expiresInMs: 60 * 1000, canBeExtended: false, }); - const onExtend = jest.fn(); const { queryByRole } = render( - + ); expect(queryByRole('button', { name: 'Stay logged in' })).toBeNull(); diff --git a/x-pack/platform/plugins/shared/security/public/session/session_expiration_toast.tsx b/x-pack/platform/plugins/shared/security/public/session/session_expiration_toast.tsx index f38638a77bc33..9e9e7f0b8d199 100644 --- a/x-pack/platform/plugins/shared/security/public/session/session_expiration_toast.tsx +++ b/x-pack/platform/plugins/shared/security/public/session/session_expiration_toast.tsx @@ -5,10 +5,8 @@ * 2.0. */ -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } 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'; @@ -23,15 +21,12 @@ import { SESSION_GRACE_PERIOD_MS } from '../../common/constants'; export interface SessionExpirationToastProps { sessionState$: Observable; - onExtend: () => Promise; } export const SessionExpirationToast: FunctionComponent = ({ sessionState$, - onExtend, }) => { const state = useObservable(sessionState$); - const [{ loading }, extend] = useAsyncFn(onExtend); if (!state || !state.expiresInMs) { return null; @@ -42,39 +37,19 @@ export const SessionExpirationToast: FunctionComponent, }} /> ); - if (state.canBeExtended) { - return ( - <> - {expirationWarning} - - - - - - - - - - ); - } - return expirationWarning; }; export const createSessionExpirationToast = ( services: StartServices, sessionState$: Observable, - onExtend: () => Promise, onClose: () => void ): ToastInput => { return { @@ -83,10 +58,7 @@ export const createSessionExpirationToast = ( title: i18n.translate('xpack.security.sessionExpirationToast.title', { defaultMessage: 'Session timeout', }), - text: toMountPoint( - , - services - ), + text: toMountPoint(, services), onClose, toastLifeTimeMs: 0x7fffffff, // Toast is hidden based on observable so using maximum possible timeout }; diff --git a/x-pack/platform/plugins/shared/security/public/session/session_timeout.test.ts b/x-pack/platform/plugins/shared/security/public/session/session_timeout.test.ts index 71180a83a8cbe..62c729c471ac7 100644 --- a/x-pack/platform/plugins/shared/security/public/session/session_timeout.test.ts +++ b/x-pack/platform/plugins/shared/security/public/session/session_timeout.test.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { ToastInputFields } from '@kbn/core/public'; import { coreMock } from '@kbn/core/public/mocks'; import { clearBroadcastChannelInstances, @@ -37,13 +36,23 @@ const nowMock = jest.spyOn(Date, 'now'); const visibilityStateMock = jest.spyOn(document, 'visibilityState', 'get'); function createSessionTimeout(expiresInMs: number | null = 60 * 60 * 1000, canBeExtended = true) { - const { notifications, http } = coreMock.createSetup(); - const coreStart = coreMock.createStart(); + const { http, notifications, overlays, ...coreStart } = coreMock.createStart(); const toast = Symbol(); + const modal = { + close: jest.fn(), + }; notifications.toasts.add.mockReturnValue(toast as any); + overlays.openModal.mockReturnValue(modal as any); const sessionExpired = createSessionExpiredMock(); const tenant = 'test'; - const sessionTimeout = new SessionTimeout(coreStart, notifications, sessionExpired, http, tenant); + const sessionTimeout = new SessionTimeout( + coreStart, + notifications, + overlays, + sessionExpired, + http, + tenant + ); http.fetch.mockResolvedValue({ expiresInMs, @@ -51,7 +60,7 @@ function createSessionTimeout(expiresInMs: number | null = 60 * 60 * 1000, canBe provider: { type: 'basic', name: 'basic1' }, } as SessionInfo); - return { sessionTimeout, sessionExpired, notifications, http }; + return { sessionTimeout, sessionExpired, notifications, http, overlays, modal }; } describe('SessionTimeout', () => { @@ -263,20 +272,19 @@ describe('SessionTimeout', () => { }); it('shows warning before session expires', async () => { - const { sessionTimeout, notifications } = createSessionTimeout(60 * 60 * 1000); + const { sessionTimeout, notifications, overlays } = createSessionTimeout(60 * 60 * 1000); await sessionTimeout.start(); jest.advanceTimersByTime( 60 * 60 * 1000 - SESSION_GRACE_PERIOD_MS - SESSION_EXPIRATION_WARNING_MS ); - expect(notifications.toasts.add).toHaveBeenCalledWith( - expect.objectContaining({ color: 'warning', iconType: 'clock' }) - ); + expect(overlays.openModal).toHaveBeenCalled(); + expect(notifications.toasts.add).not.toHaveBeenCalled(); }); it('extends session when closing expiration warning', async () => { - const { sessionTimeout, notifications, http } = createSessionTimeout(60 * 60 * 1000); + const { sessionTimeout, overlays, http } = createSessionTimeout(60 * 60 * 1000); await sessionTimeout.start(); expect(http.fetch).toHaveBeenCalledTimes(1); @@ -293,19 +301,11 @@ describe('SessionTimeout', () => { expect.objectContaining({ asSystemRequest: true }) ); - const [toast] = notifications.toasts.add.mock.calls[0] as [ToastInputFields]; - - toast.onClose!(); - - expect(http.fetch).toHaveBeenCalledTimes(3); - expect(http.fetch).toHaveBeenLastCalledWith( - SESSION_ROUTE, - expect.objectContaining({ asSystemRequest: false }) - ); + expect(overlays.openModal).toHaveBeenCalled(); }); it('show warning 5 minutes before expiration if not previously dismissed', async () => { - const { sessionTimeout, notifications } = createSessionTimeout(null); + const { sessionTimeout, notifications, overlays } = createSessionTimeout(null, false); await sessionTimeout.start(); const expiresInMs = 10 * 60 * 1000; @@ -321,10 +321,11 @@ describe('SessionTimeout', () => { jest.advanceTimersByTime(showWarningInMs); expect(notifications.toasts.add).toHaveBeenCalled(); + expect(overlays.openModal).not.toHaveBeenCalled(); }); it('do not show warning again if previously dismissed', async () => { - const { sessionTimeout, notifications } = createSessionTimeout(null); + const { sessionTimeout, notifications, overlays } = createSessionTimeout(null, false); await sessionTimeout.start(); const expiresInMs = 10 * 60 * 1000; @@ -348,6 +349,7 @@ describe('SessionTimeout', () => { // dismissed for 10 minutes we will only show it after 10 minutes have elapsed jest.advanceTimersByTime(showWarningInMs); expect(notifications.toasts.add).not.toHaveBeenCalled(); + expect(overlays.openModal).not.toHaveBeenCalled(); // Advance the timer further so that a total have 10 minutes would have passed. This is the // expiration time of the warning that was dismissed. @@ -356,19 +358,20 @@ describe('SessionTimeout', () => { }); it('hides warning if session gets extended', async () => { - const { sessionTimeout, notifications } = createSessionTimeout(60 * 60 * 1000); + const { sessionTimeout, overlays } = createSessionTimeout(60 * 60 * 1000); await sessionTimeout.start(); jest.advanceTimersByTime( 60 * 60 * 1000 - SESSION_GRACE_PERIOD_MS - SESSION_EXPIRATION_WARNING_MS ); - expect(notifications.toasts.add).toHaveBeenCalled(); + expect(overlays.openModal).toHaveBeenCalled(); // eslint-disable-next-line dot-notation await sessionTimeout['fetchSessionInfo'](true); - expect(notifications.toasts.remove).toHaveBeenCalled(); + const modalInstance = overlays.openModal.mock.results[0].value; + expect(modalInstance.close).toHaveBeenCalled(); }); it('logs user out slightly before session expires', async () => { diff --git a/x-pack/platform/plugins/shared/security/public/session/session_timeout.ts b/x-pack/platform/plugins/shared/security/public/session/session_timeout.ts index e119e43db1b72..79cc7c10b20f5 100644 --- a/x-pack/platform/plugins/shared/security/public/session/session_timeout.ts +++ b/x-pack/platform/plugins/shared/security/public/session/session_timeout.ts @@ -11,10 +11,12 @@ import { BehaviorSubject, skip, tap, throttleTime } from 'rxjs'; import type { HttpFetchOptionsWithPath, HttpSetup, - NotificationsSetup, + NotificationsStart, + OverlayStart, Toast, } from '@kbn/core/public'; +import { createSessionExpirationModal } from './session_expiration_modal'; import { createSessionExpirationToast } from './session_expiration_toast'; import type { SessionExpired } from './session_expired'; import type { StartServices } from '..'; @@ -48,6 +50,7 @@ export class SessionTimeout { private subscription?: Subscription; private warningToast?: Toast; + private warningModal?: ReturnType; private stopActivityMonitor?: Function; private stopVisibilityMonitor?: Function; @@ -59,7 +62,8 @@ export class SessionTimeout { constructor( private startServices: StartServices, - private notifications: NotificationsSetup, + private notifications: NotificationsStart, + private overlays: OverlayStart, private sessionExpired: Pick, private http: HttpSetup, private tenant: string @@ -222,6 +226,7 @@ export class SessionTimeout { return ( !this.isFetchingSessionInfo && !this.warningToast && + !this.warningModal && Date.now() > lastExtensionTime + SESSION_EXTENSION_THROTTLE_MS * Math.exp(this.consecutiveErrorCount) ); @@ -256,23 +261,26 @@ export class SessionTimeout { }; private showWarning = () => { - if (!this.warningToast) { - const onExtend = async () => { - const { canBeExtended } = this.sessionState$.getValue(); - if (canBeExtended) { - await this.fetchSessionInfo(true); - } - }; - const onClose = () => { - this.hideWarning(true); + const { canBeExtended } = this.sessionState$.getValue(); + + const onExtend = async () => { + await this.fetchSessionInfo(true); + }; + + const onClose = () => { + this.hideWarning(true); + + if (canBeExtended) { return onExtend(); - }; - const toast = createSessionExpirationToast( - this.startServices, - this.sessionState$, - onExtend, - onClose + } + }; + + if (canBeExtended && !this.warningModal) { + this.warningModal = this.overlays.openModal( + createSessionExpirationModal(this.startServices, this.sessionState$, onExtend, onClose) ); + } else if (!canBeExtended && !this.warningToast) { + const toast = createSessionExpirationToast(this.startServices, this.sessionState$, onClose); this.warningToast = this.notifications.toasts.add(toast); } }; @@ -281,9 +289,15 @@ export class SessionTimeout { if (this.warningToast) { this.notifications.toasts.remove(this.warningToast); this.warningToast = undefined; - if (snooze) { - this.snoozedWarningState = this.sessionState$.getValue(); - } + } + + if (this.warningModal) { + this.warningModal.close(); + this.warningModal = undefined; + } + + if (snooze) { + this.snoozedWarningState = this.sessionState$.getValue(); } }; }