Skip to content

Commit 7707b7d

Browse files
authored
fix(a11y): add session timeout warning modal (WCAG 2.2.1) (#1557)
* feat(a11y): expose sessionTimeout in /dashboard/api/server-config Reads spec.networking.auth.gateway.oAuthProxy.cookieExpireSeconds from the CheCluster CR (che-operator PR #1760, +kubebuilder:default:=86400) as the primary source. Falls back to parsing cookie_expire from the che-gateway-config-oauth-proxy ConfigMap for clusters running older operators where the CR field is absent. Returns -1 when neither source is available so the frontend hook is a no-op. - Add cookieExpireSeconds to CheClusterCustomResource networking type (field name is oAuthProxy per Go JSON tag in che-operator PR #1760) - Add parseDurationToSeconds helper (parses Go time.Duration strings; returns -1 on empty/unmatched input so the hook disables itself) - Add async getSessionTimeout(cheCustomResource) with CR-first + ConfigMap fallback strategy - Wire into the server-config route handler - Add selectSessionTimeout Redux selector; default unloadedState to -1 Fixes: https://issues.redhat.com/browse/CRW-10290 Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel <oorel@redhat.com> * feat(a11y): add useSessionTimeout hook for inactivity tracking Tracks UI idle time via mousemove/keydown/click/touchstart events and shows a warning modal 60 s before the OAuth session cookie expires. Activity events are ignored while the modal is open so the countdown is never accidentally reset by mouse movement. - isModalOpenRef updated synchronously alongside React state to eliminate the render/effect timing race (stale ref race) - onExtend uses try/finally so the idle timer restarts on network failure - No-op when sessionTimeout <= 0 (field absent on older operators) Fixes: https://issues.redhat.com/browse/CRW-10290 Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel <oorel@redhat.com> * feat(a11y): add SessionTimeoutModal and mount globally in App PF6 small modal with amber countdown ring (red/pulsing at <= 20 s, suppressed by prefers-reduced-motion), Extend Session and Sign Out Now buttons. Mounted in App.tsx so it appears on every page. - Icon uses var(--pf-t--global--icon--color--status--warning--default) - Closing via X button or backdrop extends the session - Space key listener active only while modal is open Satisfies WCAG 2.2.1 Timing Adjustable (Level A). Fixes: https://issues.redhat.com/browse/CRW-10290 Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel <oorel@redhat.com> --------- Signed-off-by: Oleksii Orel <oorel@redhat.com>
1 parent 7395441 commit 7707b7d

20 files changed

Lines changed: 925 additions & 1 deletion

File tree

packages/common/src/dto/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export interface IServerConfig {
136136
runTimeout: number;
137137
startTimeout: number;
138138
axiosRequestTimeout: number;
139+
sessionTimeout: number; // seconds until OAuth cookie expires; <=0 means disabled
139140
};
140141
networking?: {
141142
auth?: {

packages/dashboard-backend/src/devworkspaceClient/services/__tests__/serverConfigApi.spec.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
/* eslint-disable @typescript-eslint/no-unused-vars */
1414

1515
import * as mockClient from '@kubernetes/client-node';
16-
import { CustomObjectsApi } from '@kubernetes/client-node';
16+
import { CoreV1Api, CustomObjectsApi } from '@kubernetes/client-node';
1717

1818
import { CheClusterCustomResource, CustomResourceDefinitionList } from '@/devworkspaceClient';
1919
import {
@@ -23,6 +23,10 @@ import {
2323

2424
jest.mock('@/helpers/getUserName.ts');
2525

26+
jest.mock('@/devworkspaceClient/services/helpers/retryableExec', () => ({
27+
retryableExec: jest.fn((fn: () => Promise<unknown>) => fn()),
28+
}));
29+
2630
const mockRun = jest.fn();
2731
jest.mock('@/devworkspaceClient/services/helpers/exec', () => ({
2832
run: (command: string, args: string[]) => mockRun(command, args),
@@ -344,6 +348,71 @@ describe('Server Config API Service', () => {
344348
expect(res).toEqual('false');
345349
});
346350
});
351+
352+
describe('Session Timeout', () => {
353+
function makeServiceWithConfigMap(configMapContent: string | null): ServerConfigApiService {
354+
const { KubeConfig } = mockClient;
355+
const kc = new KubeConfig();
356+
kc.makeApiClient = jest.fn().mockImplementation((apiClass: unknown) => {
357+
if (apiClass === CoreV1Api) {
358+
if (configMapContent === null) {
359+
return {
360+
readNamespacedConfigMap: () => Promise.reject(new Error('not found')),
361+
} as unknown as CoreV1Api;
362+
}
363+
return {
364+
readNamespacedConfigMap: () =>
365+
Promise.resolve({ data: { 'oauth-proxy.cfg': configMapContent } }),
366+
} as unknown as CoreV1Api;
367+
}
368+
return {
369+
listClusterCustomObject: () => Promise.resolve(buildCustomResourceList().body),
370+
} as unknown as CustomObjectsApi;
371+
});
372+
return new ServerConfigApiService(kc);
373+
}
374+
375+
// CR-based path (new operator with che-operator PR #1760)
376+
test('returns cookieExpireSeconds from CR when set', async () => {
377+
const cr = buildCustomResource();
378+
cr.spec.networking = { auth: { gateway: { oAuthProxy: { cookieExpireSeconds: 1440 } } } };
379+
expect(await serverConfigService.getSessionTimeout(cr)).toEqual(1440);
380+
});
381+
382+
test('returns 0 when cookieExpireSeconds is 0 (session-cookie mode)', async () => {
383+
const cr = buildCustomResource();
384+
cr.spec.networking = { auth: { gateway: { oAuthProxy: { cookieExpireSeconds: 0 } } } };
385+
expect(await serverConfigService.getSessionTimeout(cr)).toEqual(0);
386+
});
387+
388+
// ConfigMap fallback path (old operator — CR field absent)
389+
test('falls back to ConfigMap and parses "24h0m0s" to 86400', async () => {
390+
const svc = makeServiceWithConfigMap('cookie_expire = "24h0m0s"');
391+
const cr = buildCustomResource(); // no oAuthProxy field
392+
expect(await svc.getSessionTimeout(cr)).toEqual(86400);
393+
});
394+
395+
test('falls back to ConfigMap and parses "30m0s" to 1800', async () => {
396+
const svc = makeServiceWithConfigMap('cookie_expire = "30m0s"');
397+
const cr = buildCustomResource();
398+
expect(await svc.getSessionTimeout(cr)).toEqual(1800);
399+
});
400+
401+
test('returns -1 when ConfigMap read fails', async () => {
402+
const svc = makeServiceWithConfigMap(null);
403+
const cr = buildCustomResource();
404+
expect(await svc.getSessionTimeout(cr)).toEqual(-1);
405+
});
406+
407+
test('returns -1 when namespace is not set', async () => {
408+
const savedNamespace = process.env['CHECLUSTER_CR_NAMESPACE'];
409+
delete process.env['CHECLUSTER_CR_NAMESPACE'];
410+
const cr = buildCustomResource();
411+
const result = await serverConfigService.getSessionTimeout(cr);
412+
process.env['CHECLUSTER_CR_NAMESPACE'] = savedNamespace;
413+
expect(result).toEqual(-1);
414+
});
415+
});
347416
});
348417

349418
function buildCustomResourceList(): { body: CustomResourceDefinitionList } {

packages/dashboard-backend/src/devworkspaceClient/services/serverConfigApi.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import path from 'path';
1919
import { requestTimeoutSeconds, startTimeoutSeconds } from '@/constants/server-config';
2020
import { createError } from '@/devworkspaceClient/services/helpers/createError';
2121
import { run } from '@/devworkspaceClient/services/helpers/exec';
22+
import {
23+
CoreV1API,
24+
prepareCoreV1API,
25+
} from '@/devworkspaceClient/services/helpers/prepareCoreV1API';
2226
import {
2327
CustomObjectAPI,
2428
prepareCustomObjectAPI,
@@ -34,16 +38,39 @@ import { logger } from '@/utils/logger';
3438

3539
const CUSTOM_RESOURCE_DEFINITIONS_API_ERROR_LABEL = 'CUSTOM_RESOURCE_DEFINITIONS_API_ERROR';
3640

41+
// Parses Go time.Duration strings: "24h0m0s", "30m0s", "1h30m".
42+
// Returns -1 for empty or unmatched input so the frontend hook disables itself.
43+
export function parseDurationToSeconds(duration: string): number {
44+
const matches = [...duration.matchAll(/(\d+(?:\.\d+)?)(h|m|s)/g)];
45+
if (matches.length === 0) {
46+
return -1;
47+
}
48+
let total = 0;
49+
for (const match of matches) {
50+
const value = parseFloat(match[1]);
51+
if (match[2] === 'h') {
52+
total += value * 3600;
53+
} else if (match[2] === 'm') {
54+
total += value * 60;
55+
} else if (match[2] === 's') {
56+
total += value;
57+
}
58+
}
59+
return Math.floor(total);
60+
}
61+
3762
const GROUP = 'org.eclipse.che';
3863
const VERSION = 'v2';
3964
const PLURAL = 'checlusters';
4065

4166
export class ServerConfigApiService implements IServerConfigApi {
4267
private readonly customObjectAPI: CustomObjectAPI;
68+
private readonly coreV1API: CoreV1API;
4369
private static currentArchitecture: Architecture | undefined;
4470

4571
constructor(kc: k8s.KubeConfig) {
4672
this.customObjectAPI = prepareCustomObjectAPI(kc);
73+
this.coreV1API = prepareCoreV1API(kc);
4774

4875
if (isLocalRun()) {
4976
ServerConfigApiService.currentArchitecture = process.env[
@@ -311,6 +338,38 @@ export class ServerConfigApiService implements IServerConfigApi {
311338
}
312339
return value.split(',').map(val => val.trim());
313340
}
341+
342+
async getSessionTimeout(cheCustomResource: CheClusterCustomResource): Promise<number> {
343+
// Prefer cookieExpireSeconds from the CheCluster CR (che-operator PR #1760).
344+
// The CRD carries +kubebuilder:default:=86400, so new clusters always have it populated.
345+
const fromCR =
346+
cheCustomResource.spec.networking?.auth?.gateway?.oAuthProxy?.cookieExpireSeconds;
347+
if (fromCR !== undefined) {
348+
return fromCR;
349+
}
350+
// Fallback: read cookie_expire from the oauth-proxy ConfigMap.
351+
// Covers existing CheCluster CRs created before the operator was updated to include PR #1760,
352+
// where the field is absent but the ConfigMap still holds the real enforced value.
353+
const CONFIGMAP_NAME = 'che-gateway-config-oauth-proxy';
354+
const namespace = this.env.NAMESPACE;
355+
if (!namespace) {
356+
return -1;
357+
}
358+
try {
359+
const response = await this.coreV1API.readNamespacedConfigMap({
360+
name: CONFIGMAP_NAME,
361+
namespace,
362+
});
363+
const cfg = response.data?.['oauth-proxy.cfg'] ?? '';
364+
const cookieExpireMatch = /cookie_expire\s*=\s*"([^"]+)"/.exec(cfg);
365+
if (cookieExpireMatch) {
366+
return parseDurationToSeconds(cookieExpireMatch[1]);
367+
}
368+
} catch (e) {
369+
logger.warn(`Unable to read ConfigMap ${CONFIGMAP_NAME}: ${e}`);
370+
}
371+
return -1;
372+
}
314373
}
315374

316375
export function getEnvVarValue(

packages/dashboard-backend/src/devworkspaceClient/types/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ export type CheClusterCustomResource = k8s.V1CustomResourceDefinition & {
136136
networking?: {
137137
auth?: {
138138
advancedAuthorization?: api.IAdvancedAuthorization;
139+
gateway?: {
140+
oAuthProxy?: {
141+
// Added in che-operator PR #1760
142+
cookieExpireSeconds?: number;
143+
};
144+
};
139145
};
140146
};
141147
};
@@ -413,6 +419,15 @@ export interface IServerConfigApi {
413419
*/
414420
getHideEditorsById(cheCustomResource: CheClusterCustomResource): string[];
415421

422+
/**
423+
* Returns the OAuth session timeout in seconds.
424+
* Reads spec.networking.auth.gateway.oAuthProxy.cookieExpireSeconds from the CR first
425+
* (che-operator PR #1760, +kubebuilder:default:=86400). Falls back to parsing cookie_expire
426+
* from the che-gateway-config-oauth-proxy ConfigMap for clusters running older operators
427+
* where the CR field is absent. Returns -1 when neither source is available.
428+
*/
429+
getSessionTimeout(cheCustomResource: CheClusterCustomResource): Promise<number>;
430+
416431
/**
417432
* Returns the Machine (hardware) type.
418433
* The value is determined by executing the `uname -m` command.

packages/dashboard-backend/src/routes/api/__tests__/serverConfig.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe('Server Config Route', () => {
5252
runTimeout: 0,
5353
startTimeout: 0,
5454
axiosRequestTimeout: 0,
55+
sessionTimeout: 86400, // stub value from mock
5556
},
5657
editorsVisibility: {
5758
hideById: ['che-incubator/che-idea-server/next'],

packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ export const stubShowDeprecatedEditors = true;
176176

177177
export const stubHideEditorsById: string[] = ['che-incubator/che-idea-server/next'];
178178

179+
export const stubSessionTimeout = 86400;
180+
179181
export const getDevWorkspaceClient = jest.fn(
180182
(..._args: Parameters<typeof helper>): ReturnType<typeof helper> => {
181183
return {
@@ -205,6 +207,7 @@ export const getDevWorkspaceClient = jest.fn(
205207
getAllowedSourceUrls: _cheCustomResource => stubAllowedSourceUrls,
206208
getShowDeprecatedEditors: _cheCustomResource => stubShowDeprecatedEditors,
207209
getHideEditorsById: _cheCustomResource => stubHideEditorsById,
210+
getSessionTimeout: _cheCustomResource => Promise.resolve(stubSessionTimeout),
208211
} as IServerConfigApi,
209212
devworkspaceApi: {
210213
create: (_devworkspace, _namespace) =>

packages/dashboard-backend/src/routes/api/serverConfig.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export function registerServerConfigRoute(instance: FastifyInstance) {
5252
const axiosRequestTimeout = serverConfigApi.getAxiosRequestTimeout();
5353
const showDeprecated = serverConfigApi.getShowDeprecatedEditors(cheCustomResource);
5454
const hideById = serverConfigApi.getHideEditorsById(cheCustomResource);
55+
const sessionTimeout = await serverConfigApi.getSessionTimeout(cheCustomResource);
5556

5657
const serverConfig: api.IServerConfig = {
5758
containerBuild,
@@ -67,6 +68,7 @@ export function registerServerConfigRoute(instance: FastifyInstance) {
6768
runTimeout,
6869
startTimeout,
6970
axiosRequestTimeout,
71+
sessionTimeout,
7072
},
7173
devfileRegistry: {
7274
disableInternalRegistry,

packages/dashboard-frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { HashRouter } from 'react-router-dom';
1919
import AppAlertGroup from '@/components/AppAlertGroup';
2020
import Fallback from '@/components/Fallback';
2121
import Head from '@/components/Head';
22+
import SessionTimeoutModal from '@/components/SessionTimeoutModal';
2223
import { ThemeProvider } from '@/contexts/ThemeContext';
2324
import { UIThemeProvider } from '@/contexts/UITheme';
2425
import Layout from '@/Layout';
@@ -31,6 +32,7 @@ function AppComponent(props: { history: History }): React.ReactElement {
3132
<HashRouter>
3233
<Head />
3334
<AppAlertGroup />
35+
<SessionTimeoutModal />
3436
<Layout history={props.history}>
3537
<Suspense fallback={Fallback}>
3638
<AppRoutes />

0 commit comments

Comments
 (0)