From 13048f3156d450cfb65b307af1252fa5f8d0df9d Mon Sep 17 00:00:00 2001 From: Prabinder Singh Date: Fri, 8 May 2026 03:48:38 +0530 Subject: [PATCH 1/8] backend: auth: Expose token expiry time in /me response Add tokenExpiry Unix timestamp to writeMeResponse so the frontend can track session lifetime without reading httpOnly cookies. --- backend/pkg/auth/auth.go | 29 ++++++++++++++++++++++------- backend/pkg/auth/auth_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/backend/pkg/auth/auth.go b/backend/pkg/auth/auth.go index b924571e529..118b0c201c7 100644 --- a/backend/pkg/auth/auth.go +++ b/backend/pkg/auth/auth.go @@ -380,7 +380,8 @@ func HandleMe(opts MeHandlerOptions) http.HandlerFunc { return } - if expiry, err := GetExpiryUnixTimeUTC(claims); err != nil || time.Now().After(expiry) { + expiry, err := GetExpiryUnixTimeUTC(claims) + if err != nil || time.Now().After(expiry) { writeMeJSON(w, http.StatusUnauthorized, map[string]interface{}{"message": "token expired"}) return } @@ -389,7 +390,7 @@ func HandleMe(opts MeHandlerOptions) http.HandlerFunc { email := stringValueFromJMESPaths(claims, compiledEmailPaths) groups := stringSliceFromJMESPaths(claims, compiledGroupsPaths) - writeMeResponse(w, username, email, groups, userInfoURL) + writeMeResponse(w, username, email, groups, userInfoURL, expiry) } } @@ -435,19 +436,33 @@ func tryProxyAuth(w http.ResponseWriter, r *http.Request, opts MeHandlerOptions, } } - writeMeResponse(w, username, email, groups, userInfoURL) + writeMeResponse(w, username, email, groups, userInfoURL, time.Time{}) return true } -// writeMeResponse writes the successful response for HandleMe with the standard cache-busting headers. -func writeMeResponse(w http.ResponseWriter, username, email string, groups []string, userInfoURL string) { - writeMeJSON(w, http.StatusOK, map[string]interface{}{ +// writeMeResponse serializes the identity payload with the standard cache-busting headers. +// tokenExpiry is the token's expiry time; if non-zero it is included so the frontend +// can warn users before their session ends. +func writeMeResponse( + w http.ResponseWriter, + username, email string, + groups []string, + userInfoURL string, + tokenExpiry time.Time, +) { + payload := map[string]interface{}{ "username": username, "email": email, "groups": groups, "userInfoURL": userInfoURL, - }) + } + + if !tokenExpiry.IsZero() { + payload["tokenExpiry"] = tokenExpiry.Unix() + } + + writeMeJSON(w, http.StatusOK, payload) } // writeMeJSON sets the standard cache-control headers used by /me responses and writes the JSON payload. diff --git a/backend/pkg/auth/auth_test.go b/backend/pkg/auth/auth_test.go index 7fa826360b2..b241e3ebf0d 100644 --- a/backend/pkg/auth/auth_test.go +++ b/backend/pkg/auth/auth_test.go @@ -1345,3 +1345,38 @@ func TestHandleMe_MissingCookie(t *testing.T) { assert.Equal(t, "no-store, no-cache, must-revalidate, private", rr.Header().Get("Cache-Control")) assert.Equal(t, "Cookie", rr.Header().Get("Vary")) } + +func TestHandleMe_IncludesTokenExpiry(t *testing.T) { + t.Parallel() + + futureExpiry := time.Now().Add(time.Hour).Unix() + claims := map[string]interface{}{ + "preferred_username": "alice", + "exp": float64(futureExpiry), + } + + token := makeTestToken(t, claims) + + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/clusters/test/me", nil) + req = mux.SetURLVars(req, map[string]string{"clusterName": "test"}) + req.Header.Set("Authorization", "Bearer "+token) + + rr := httptest.NewRecorder() + + handler := auth.HandleMe(auth.MeHandlerOptions{ + UsernamePaths: "preferred_username", + }) + + handler(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + + var got struct { + Username string `json:"username"` + TokenExpiry int64 `json:"tokenExpiry"` + } + + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got)) + assert.Equal(t, "alice", got.Username) + assert.Equal(t, futureExpiry, got.TokenExpiry, "tokenExpiry should match the JWT exp claim as a Unix timestamp") +} From f1c38ae140840aaf7fe46b9dab09de808e3505e3 Mon Sep 17 00:00:00 2001 From: Prabinder Singh Date: Fri, 8 May 2026 03:48:34 +0530 Subject: [PATCH 2/8] frontend: Add TokenExpiryNotification session expiry warning banner --- frontend/src/components/App/Layout.tsx | 2 + .../App/TokenExpiryNotification.tsx | 264 ++++++++++++++++++ frontend/src/i18n/locales/de/translation.json | 4 +- frontend/src/i18n/locales/en/translation.json | 4 +- frontend/src/i18n/locales/es/translation.json | 4 +- frontend/src/i18n/locales/fr/translation.json | 4 +- frontend/src/i18n/locales/hi/translation.json | 224 +++++++-------- frontend/src/i18n/locales/it/translation.json | 4 +- frontend/src/i18n/locales/ja/translation.json | 4 +- frontend/src/i18n/locales/ko/translation.json | 4 +- frontend/src/i18n/locales/pt/translation.json | 4 +- frontend/src/i18n/locales/ta/translation.json | 4 +- .../src/i18n/locales/zh-tw/translation.json | 4 +- frontend/src/i18n/locales/zh/translation.json | 4 +- frontend/src/lib/auth.ts | 48 ++++ 15 files changed, 460 insertions(+), 122 deletions(-) create mode 100644 frontend/src/components/App/TokenExpiryNotification.tsx diff --git a/frontend/src/components/App/Layout.tsx b/frontend/src/components/App/Layout.tsx index d45abb43f67..9742342049b 100644 --- a/frontend/src/components/App/Layout.tsx +++ b/frontend/src/components/App/Layout.tsx @@ -48,6 +48,7 @@ import Sidebar, { NavigationTabs } from '../Sidebar'; import RouteSwitcher from './RouteSwitcher'; import ShortcutsSettings from './Settings/ShortcutsSettings'; import { applyBackendThemeConfig } from './themeSlice'; +import TokenExpiryNotification from './TokenExpiryNotification'; import TopBar from './TopBar'; import VersionDialog from './VersionDialog'; @@ -346,6 +347,7 @@ export default function Layout({}: LayoutProps) { ))} +
diff --git a/frontend/src/components/App/TokenExpiryNotification.tsx b/frontend/src/components/App/TokenExpiryNotification.tsx new file mode 100644 index 00000000000..31aab20542f --- /dev/null +++ b/frontend/src/components/App/TokenExpiryNotification.tsx @@ -0,0 +1,264 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { matchPath, useLocation } from 'react-router-dom'; +import type { ClusterMeResult } from '../../lib/auth'; +import { fetchClusterMe, logout } from '../../lib/auth'; +import { getCluster } from '../../lib/cluster'; +import { getRoute } from '../../lib/router/getRoute'; +import { getRoutePath } from '../../lib/router/getRoutePath'; + +/** How often to poll the /clusters/:cluster/me endpoint (ms). */ +const POLL_INTERVAL_MS = 60 * 1000; + +/** Show a warning banner when fewer than this many seconds remain before expiry. */ +const WARNING_BEFORE_EXPIRY_SECONDS = 2 * 60; + +/** Routes where the banner is suppressed — these pages handle auth state themselves. */ +const ROUTES_WITHOUT_EXPIRY_CHECK = ['login', 'token', 'settingsCluster']; + +export interface PureTokenExpiryNotificationProps { + /** Injected fetch function so tests can control responses without hitting the network. */ + fetchClusterMeFn: (cluster: string) => Promise; +} + +/** + * Polls the Headlamp /clusters/:cluster/me endpoint and shows a banner when the + * session token is about to expire or has already expired. + * + * Exported as `PureTokenExpiryNotification` so it can be unit-tested with a + * mocked fetch function. + */ +export function PureTokenExpiryNotification({ + fetchClusterMeFn, +}: PureTokenExpiryNotificationProps) { + const { t } = useTranslation(); + const { pathname } = useLocation(); + const clusterName = getCluster(); + + const [tokenExpiry, setTokenExpiry] = React.useState(null); + const [tokenExpired, setTokenExpired] = React.useState(false); + // Tracks wall-clock seconds; updated every second once we have an expiry to count down. + const [now, setNow] = React.useState(() => Math.floor(Date.now() / 1000)); + + // Restart the poller whenever the cluster changes. + React.useEffect(() => { + setTokenExpiry(null); + setTokenExpired(false); + setNow(Math.floor(Date.now() / 1000)); + + const cluster = clusterName; + if (!cluster) { + return; + } + + let mounted = true; + + const check = async () => { + if (!mounted) return; + const result = await fetchClusterMeFn(cluster); + if (!mounted) return; + + if (result.tokenExpired) { + setTokenExpired(true); + } else if (result.data?.tokenExpiry !== null && result.data?.tokenExpiry !== undefined) { + setTokenExpiry(result.data.tokenExpiry); + } + }; + + // Run once immediately, then on the regular interval. + check(); + const id = setInterval(check, POLL_INTERVAL_MS); + + return () => { + mounted = false; + clearInterval(id); + }; + }, [clusterName, fetchClusterMeFn]); + + // Keep `now` in sync with wall-clock time so the countdown text is live. + // Defer the interval until the warning window begins to avoid ticking for + // hours when the token has a long lifetime. + React.useEffect(() => { + if (tokenExpiry === null) return; + + const secondsLeft = tokenExpiry - Math.floor(Date.now() / 1000); + let intervalId: ReturnType | null = null; + + if (secondsLeft <= WARNING_BEFORE_EXPIRY_SECONDS) { + intervalId = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000); + return () => clearInterval(intervalId!); + } + + const delay = (secondsLeft - WARNING_BEFORE_EXPIRY_SECONDS) * 1000; + const timeoutId = setTimeout(() => { + setNow(Math.floor(Date.now() / 1000)); + intervalId = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000); + }, delay); + + return () => { + clearTimeout(timeoutId); + if (intervalId !== null) clearInterval(intervalId); + }; + }, [tokenExpiry]); + + // Detect local expiry between polls so logout happens immediately rather + // than waiting up to POLL_INTERVAL_MS after the token has already expired. + React.useEffect(() => { + if (tokenExpiry !== null && now >= tokenExpiry && !tokenExpired) { + setTokenExpired(true); + } + }, [now, tokenExpiry, tokenExpired]); + + // Auto-logout as soon as we know the token is expired (either from the + // backend poll or from the local clock check above). + React.useEffect(() => { + if (!tokenExpired) return; + if (clusterName) { + logout(clusterName); + } + }, [tokenExpired, clusterName]); + + const showOnRoute = React.useMemo(() => { + for (const routeName of ROUTES_WITHOUT_EXPIRY_CHECK) { + const maybeRoute = getRoute(routeName); + if (!maybeRoute) continue; + if (matchPath(pathname, getRoutePath(maybeRoute))?.isExact) return false; + } + return true; + }, [pathname]); + + if (!showOnRoute || !getCluster()) { + return null; + } + + if (tokenExpired) { + return ( + ({ + color: theme.palette.common.white, + background: theme.palette.error.main, + textAlign: 'center', + display: 'flex', + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(1), + paddingRight: theme.spacing(3), + justifyContent: 'center', + position: 'fixed', + zIndex: theme.zIndex.snackbar + 1, + top: '0', + alignItems: 'center', + left: '50%', + width: 'auto', + transform: 'translateX(-50%)', + })} + > + ({ + paddingTop: theme.spacing(0.5), + fontWeight: 'bold', + fontSize: '16px', + })} + > + {t('translation|Session expired. Logging out…')} + + + ); + } + + const secondsLeft = tokenExpiry !== null ? tokenExpiry - now : null; + const isExpiring = + secondsLeft !== null && secondsLeft > 0 && secondsLeft <= WARNING_BEFORE_EXPIRY_SECONDS; + + if (!isExpiring) { + return null; + } + + const minutes = Math.floor(secondsLeft! / 60); + const seconds = secondsLeft! % 60; + const timeStr = `${minutes}:${String(seconds).padStart(2, '0')}`; + + return ( + ({ + color: theme.palette.common.white, + background: theme.palette.warning.main, + textAlign: 'center', + display: 'flex', + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(1), + paddingRight: theme.spacing(3), + justifyContent: 'center', + position: 'fixed', + zIndex: theme.zIndex.snackbar + 1, + top: '0', + alignItems: 'center', + left: '50%', + width: 'auto', + transform: 'translateX(-50%)', + })} + action={ + + } + > + ({ + paddingTop: theme.spacing(0.5), + fontWeight: 'bold', + fontSize: '16px', + })} + > + {t('translation|Session expires in {{time}}', { time: timeStr })} + + + ); +} + +export default function TokenExpiryNotification() { + return ; +} diff --git a/frontend/src/i18n/locales/de/translation.json b/frontend/src/i18n/locales/de/translation.json index e62558ff9ad..243d396617e 100644 --- a/frontend/src/i18n/locales/de/translation.json +++ b/frontend/src/i18n/locales/de/translation.json @@ -196,8 +196,10 @@ "Navigation": "Navigation", "General": "", "Reset All to Defaults": "", - "Log out from all": "", + "Session expired. Logging out…": "", "Log out": "Abmelden", + "Session expires in {{time}}": "", + "Log out from all": "", "Account of current user": "Konto des aktuellen Benutzers", "Appbar Tools": "Appbar-Tools", "show more": "mehr anzeigen", diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index a76fe7c1a6b..30de57c7846 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -196,8 +196,10 @@ "Navigation": "Navigation", "General": "General", "Reset All to Defaults": "Reset All to Defaults", - "Log out from all": "Log out from all", + "Session expired. Logging out…": "Session expired. Logging out…", "Log out": "Log out", + "Session expires in {{time}}": "Session expires in {{time}}", + "Log out from all": "Log out from all", "Account of current user": "Account of current user", "Appbar Tools": "Appbar Tools", "show more": "show more", diff --git a/frontend/src/i18n/locales/es/translation.json b/frontend/src/i18n/locales/es/translation.json index 4dc5a09d245..a5712d68067 100644 --- a/frontend/src/i18n/locales/es/translation.json +++ b/frontend/src/i18n/locales/es/translation.json @@ -196,8 +196,10 @@ "Navigation": "Navegación", "General": "General", "Reset All to Defaults": "", - "Log out from all": "", + "Session expired. Logging out…": "", "Log out": "Desconectar", + "Session expires in {{time}}": "", + "Log out from all": "", "Account of current user": "Cuenta del usuario actual", "Appbar Tools": "Herramientas de la barra de aplicación", "show more": "mostrar más", diff --git a/frontend/src/i18n/locales/fr/translation.json b/frontend/src/i18n/locales/fr/translation.json index b8c1d6b0d02..1fb6addff74 100644 --- a/frontend/src/i18n/locales/fr/translation.json +++ b/frontend/src/i18n/locales/fr/translation.json @@ -196,8 +196,10 @@ "Navigation": "Navigation", "General": "Général", "Reset All to Defaults": "", - "Log out from all": "", + "Session expired. Logging out…": "", "Log out": "Déconnexion", + "Session expires in {{time}}": "", + "Log out from all": "", "Account of current user": "Compte de l'utilisateur actuel", "Appbar Tools": "Outils de la barre d'application", "show more": "afficher plus", diff --git a/frontend/src/i18n/locales/hi/translation.json b/frontend/src/i18n/locales/hi/translation.json index 3a820de800a..2b8d04aed45 100644 --- a/frontend/src/i18n/locales/hi/translation.json +++ b/frontend/src/i18n/locales/hi/translation.json @@ -162,117 +162,119 @@ "Use evict for pod deletion": "", "Theme": "", "Theme has been forced by your administrator": "", - "Keyboard Shortcuts": "", - "Configure keyboard shortcuts for quick access to various parts of the application.": "", - "Cluster Settings": "", - "There seem to be no clusters configured…": "", - "Cluster {{ clusterName }} does not exist. Please select a valid cluster:": "", - "Go to cluster": "", - "Appearance": "", - "Stored in your browser's localStorage (per-browser setting).": "", - "Accent color": "", - "Change Color": "", - "Choose Color": "", - "Clear accent color": "", - "Cluster icon": "", - "Change Icon": "", - "Choose Icon": "", - "Clear cluster icon": "", - "Accent color format is invalid. Use hex (#ff0000), rgb(), rgba(), or a CSS color name.": "", - "Apply appearance": "", - "Apply appearance changes for \"{{ clusterName }}\"? This will be stored in your browser.": "", - "Applying...": "", - "Default namespace": "", - "The default namespace for e.g. when applying resources (when not specified directly).": "", - "Allowed namespaces": "", - "The list of namespaces you are allowed to access in this cluster.": "", - "Add namespace": "", - "Remove Cluster": "", - "Are you sure you want to remove the cluster \"{{ clusterName }}\"?": "", - "Server": "", - "Press keys...": "", - "Conflicts with: {{name}}": "", - "Reset to default": "", - "Navigation": "", - "General": "", - "Reset All to Defaults": "", - "Log out from all": "", - "Log out": "", - "Account of current user": "", - "Appbar Tools": "", - "show more": "", - "Version information": "", - "Getting auth info: {{ clusterName }}": "", - "Getting auth info": "", - "Testing auth": "", - "Headlamp Cluster Authentication": "", - "Sign In": "", - "Use A Token": "", - "Failed to connect. Please make sure the Kubernetes cluster is running and accessible. Error: {{ errorMessage }}": "", - "Failed to get authentication information: {{ errorMessage }}": "", - "Try Again": "", - "Back": "", - "Memory Usage": "", - "{{ available }} units": "", - "CPU Usage": "", - "{{ used }} / {{ total }} Pods": "", - "Pod Usage": "", - "Ephemeral Storage Usage": "", - "Unable to fetch ephemeral storage usage from kubelet.": "", - "{{ numReady }} / {{ numItems }} Requested": "", - "{{ numReady }} / {{ numItems }} Ready": "", - "Recent clusters": "", - "All clusters": "", - "Show build information": "", - "Choose a cluster": "", - "Wait while fetching clusters…": "", - "Loading cluster information": "", - "There seems to be no clusters configured…": "", - "Please make sure you have at least one cluster configured.": "", - "Or try running Headlamp with a different kube config.": "", - "Load from a file": "", - "{{count}} clusters_one": "", - "{{count}} clusters_other": "", - "Current//context:cluster": "", - "Choose cluster": "", - "Failed to load resources": "", - "You don't have permissions to view this resource": "", - "Resource not found": "", - "Hide details": "", - "Show details": "", - "Drag & drop or choose kubeconfig file here": "", - "Choose file": "", - "Select clusters": "", - "Next": "", - "Validating selected clusters": "", - "Setting up clusters": "", - "Clusters successfully set up!": "", - "Finish": "", - "Duplicate cluster: {{ clusterNames }} in the list. Please edit the context name.": "", - "Error setting up clusters, please load a valid kubeconfig file": "", - "Couldn't read kubeconfig file": "", - "No clusters found!": "", - "No contexts found!": "", - "Invalid kubeconfig file: {{ errorMessage }}": "", - "Only warnings ({{ numWarnings }})": "", - "Reason": "", - "Count": "", - "Last Seen": "", - "Offline": "", - "Lost connection to the cluster.": "", - "No": "", - "Yes": "", - "Create {{ name }}": "", - "Toggle fullscreen": "", - "Unable to open GitHub. Please check your popup blocker settings or copy the error details manually.": "", - "Head back <1>home.": "", - "Error Details": "", - "Copied to clipboard": "", - "Failed to copy to clipboard": "", - "Copy": "", - "Open Issue on GitHub": "", - "Find": "", - "Download": "", + "Keyboard Shortcuts": "कीबोर्ड शॉर्टकट्स", + "Configure keyboard shortcuts for quick access to various parts of the application.": "एप्लिकेशन के विभिन्न हिस्सों तक तेज़ पहुँच के लिए कीबोर्ड शॉर्टकट्स कॉन्फ़िगर करें।", + "Cluster Settings": "क्लस्टर सेटिंग्स", + "There seem to be no clusters configured…": "ऐसा लगता है कि कोई क्लस्टर कॉन्फ़िगर नहीं किया गया है…", + "Cluster {{ clusterName }} does not exist. Please select a valid cluster:": "क्लस्टर {{ clusterName }} मौजूद नहीं है। कृपया वैध क्लस्टर चुनें:", + "Go to cluster": "क्लस्टर पर जाएँ", + "Appearance": "दिखावट", + "Stored in your browser's localStorage (per-browser setting).": "आपके ब्राउज़र के localStorage में संग्रहीत (ब्राउज़र-विशिष्ट सेटिंग)।", + "Accent color": "एक्सेंट रंग", + "Change Color": "रंग बदलें", + "Choose Color": "रंग चुनें", + "Clear accent color": "एक्सेंट रंग साफ़ करें", + "Cluster icon": "क्लस्टर आइकन", + "Change Icon": "आइकन बदलें", + "Choose Icon": "आइकन चुनें", + "Clear cluster icon": "क्लस्टर आइकन साफ़ करें", + "Accent color format is invalid. Use hex (#ff0000), rgb(), rgba(), or a CSS color name.": "एक्सेंट रंग का प्रारूप अमान्य है। hex (#ff0000), rgb(), rgba(), या CSS रंग का नाम का उपयोग करें।", + "Apply appearance": "दिखावट लागू करें", + "Apply appearance changes for \"{{ clusterName }}\"? This will be stored in your browser.": "\"{{ clusterName }}\" के लिए दिखावट परिवर्तन लागू करें? यह आपके ब्राउज़र में संग्रहीत होगा।", + "Applying...": "लागू कर रहे हैं...", + "Default namespace": "डिफ़ॉल्ट namespace", + "The default namespace for e.g. when applying resources (when not specified directly).": "डिफ़ॉल्ट namespace उदाहरण के लिए जब संसाधन लागू करते हैं (जब सीधे निर्दिष्ट नहीं किया जाता है)।", + "Allowed namespaces": "अनुमत namespaces", + "The list of namespaces you are allowed to access in this cluster.": "उन नेमस्पेस की सूची जिन्हें आप इस क्लस्टर में एक्सेस करने के लिए अधिकृत हैं।", + "Add namespace": "Namespace जोड़ें", + "Remove Cluster": "क्लस्टर हटाएँ", + "Are you sure you want to remove the cluster \"{{ clusterName }}\"?": "क्या आप वाकई क्लस्टर \"{{ clusterName }}\" को हटाना चाहते हैं?", + "Server": "सर्वर", + "Press keys...": "कुंजियाँ दबाएँ...", + "Conflicts with: {{name}}": "{{name}} से टकराव है", + "Reset to default": "डिफ़ॉल्ट पर रीसेट करें", + "Navigation": "नेविगेशन", + "General": "जेनरल", + "Reset All to Defaults": "सभी को डिफ़ॉल्ट पर रीसेट करें", + "Session expired. Logging out…": "", + "Log out": "लॉग आउट", + "Session expires in {{time}}": "", + "Log out from all": "सभी से लॉग आउट करें", + "Account of current user": "वर्तमान उपयोगकर्ता का खाता", + "Appbar Tools": "ऐपबार टूल्स", + "show more": "अधिक दिखाएँ", + "Version information": "संस्करण जानकारी", + "Getting auth info: {{ clusterName }}": "प्रमाणीकरण जानकारी प्राप्त कर रहे हैं: {{ clusterName }}", + "Getting auth info": "प्रमाणीकरण जानकारी प्राप्त कर रहे हैं", + "Testing auth": "प्रमाणीकरण का परीक्षण कर रहे हैं", + "Headlamp Cluster Authentication": "Headlamp क्लस्टर प्रमाणीकरण", + "Sign In": "साइन इन करें", + "Use A Token": "टोकन का उपयोग करें", + "Failed to connect. Please make sure the Kubernetes cluster is running and accessible. Error: {{ errorMessage }}": "कनेक्ट करने में विफल। कृपया सुनिश्चित करें कि Kubernetes क्लस्टर चल रहा है और पहुँच योग्य है। त्रुटि: {{ errorMessage }}", + "Failed to get authentication information: {{ errorMessage }}": "प्रमाणीकरण जानकारी प्राप्त करने में विफल: {{ errorMessage }}", + "Try Again": "पुनः प्रयास करें", + "Back": "वापस जाएँ", + "Memory Usage": "मेमोरी उपयोग", + "{{ available }} units": "{{ available }} इकाइयाँ", + "CPU Usage": "CPU उपयोग", + "{{ used }} / {{ total }} Pods": "{{ used }} / {{ total }} पॉड्स", + "Pod Usage": "पॉड उपयोग", + "Ephemeral Storage Usage": "एफेमेरल स्टोरेज उपयोग", + "Unable to fetch ephemeral storage usage from kubelet.": "kubelet से एफेमेरल स्टोरेज उपयोग प्राप्त करने में असमर्थ।", + "{{ numReady }} / {{ numItems }} Requested": "{{ numReady }} / {{ numItems }} अनुरोधित", + "{{ numReady }} / {{ numItems }} Ready": "{{ numReady }} / {{ numItems }} तैयार", + "Recent clusters": "हाल के क्लस्टर", + "All clusters": "सभी क्लस्टर", + "Show build information": "बिल्ड जानकारी दिखाएँ", + "Choose a cluster": "एक क्लस्टर चुनें", + "Wait while fetching clusters…": "क्लस्टर प्राप्त करते समय प्रतीक्षा करें…", + "Loading cluster information": "क्लस्टर जानकारी लोड हो रही है", + "There seems to be no clusters configured…": "ऐसा लगता है कि कोई क्लस्टर कॉन्फ़िगर नहीं किया गया है…", + "Please make sure you have at least one cluster configured.": "कृपया सुनिश्चित करें कि आपके पास कम से कम एक क्लस्टर कॉन्फ़िगर किया गया है।", + "Or try running Headlamp with a different kube config.": "या Headlamp को एक अलग kube कॉन्फिग के साथ चलाने का प्रयास करें।", + "Load from a file": "फ़ाइल से लोड करें", + "{{count}} clusters_one": "{{count}} क्लस्टर", + "{{count}} clusters_other": "{{count}} क्लस्टर्स", + "Current//context:cluster": "वर्तमान", + "Choose cluster": "क्लस्टर चुनें", + "Failed to load resources": "संसाधन लोड करने में विफल", + "You don't have permissions to view this resource": "आपके पास इस संसाधन को देखने की अनुमति नहीं है", + "Resource not found": "संसाधन नहीं मिला", + "Hide details": "विवरण छिपाएँ", + "Show details": "विवरण दिखाएँ", + "Drag & drop or choose kubeconfig file here": "यहां kubeconfig फ़ाइल ड्रैग और ड्रॉप करें या चुनें", + "Choose file": "फ़ाइल चुनें", + "Select clusters": "क्लस्टर चुनें", + "Next": "अगला", + "Validating selected clusters": "चयनित क्लस्टर सत्यापित कर रहे हैं", + "Setting up clusters": "क्लस्टर सेट अप कर रहे हैं", + "Clusters successfully set up!": "क्लस्टर सफलतापूर्वक सेट अप किए गए!", + "Finish": "समाप्त करें", + "Duplicate cluster: {{ clusterNames }} in the list. Please edit the context name.": "डुप्लिकेट क्लस्टर: सूची में {{ clusterNames }}। कृपया संदर्भ नाम संपादित करें।", + "Error setting up clusters, please load a valid kubeconfig file": "क्लस्टर सेट अप करने में त्रुटि, कृपया वैध kubeconfig फ़ाइल लोड करें", + "Couldn't read kubeconfig file": "kubeconfig फ़ाइल पढ़ नहीं सका", + "No clusters found!": "कोई क्लस्टर नहीं मिला!", + "No contexts found!": "कोई संदर्भ नहीं मिला!", + "Invalid kubeconfig file: {{ errorMessage }}": "अमान्य kubeconfig फ़ाइल: {{ errorMessage }}", + "Only warnings ({{ numWarnings }})": "केवल चेतावनियाँ ({{ numWarnings }})", + "Reason": "कारण", + "Count": "गणना", + "Last Seen": "अंतिम बार देखा गया", + "Offline": "ऑफलाइन", + "Lost connection to the cluster.": "क्लस्टर से कनेक्शन खो गया।", + "No": "नहीं", + "Yes": "हाँ", + "Create {{ name }}": "{{ name }} बनाएँ", + "Toggle fullscreen": "फुलस्क्रीन टॉगल करें", + "Unable to open GitHub. Please check your popup blocker settings or copy the error details manually.": "GitHub खोलने में असमर्थ। कृपया अपने पॉपअप ब्लॉकर सेटिंग्स की जाँच करें या त्रुटि विवरण हाथ से कॉपी करें।", + "Head back <1>home.": "<1>होम पर वापस जाएँ।", + "Error Details": "त्रुटि विवरण", + "Copied to clipboard": "क्लिपबोर्ड पर कॉपी किया गया", + "Failed to copy to clipboard": "क्लिपबोर्ड पर कॉपी करने में विफल", + "Copy": "कॉपी करें", + "Open Issue on GitHub": "GitHub पर समस्या खोलें", + "Find": "खोजें", + "Download": "डाउनलोड करें", "Reconnect": "", "No results": "", "Too many matches": "", diff --git a/frontend/src/i18n/locales/it/translation.json b/frontend/src/i18n/locales/it/translation.json index ac79713d2f8..909a1646d32 100644 --- a/frontend/src/i18n/locales/it/translation.json +++ b/frontend/src/i18n/locales/it/translation.json @@ -196,8 +196,10 @@ "Navigation": "Navigazione", "General": "Generale", "Reset All to Defaults": "", - "Log out from all": "", + "Session expired. Logging out…": "", "Log out": "Disconnetti", + "Session expires in {{time}}": "", + "Log out from all": "", "Account of current user": "Account dell'utente corrente", "Appbar Tools": "Strumenti Appbar", "show more": "mostra di più", diff --git a/frontend/src/i18n/locales/ja/translation.json b/frontend/src/i18n/locales/ja/translation.json index 938b7e57613..5cba787728e 100644 --- a/frontend/src/i18n/locales/ja/translation.json +++ b/frontend/src/i18n/locales/ja/translation.json @@ -196,8 +196,10 @@ "Navigation": "ナビゲーション", "General": "一般", "Reset All to Defaults": "", - "Log out from all": "", + "Session expired. Logging out…": "", "Log out": "ログアウト", + "Session expires in {{time}}": "", + "Log out from all": "", "Account of current user": "現在のユーザーのアカウント", "Appbar Tools": "アプリバーツール", "show more": "もっと表示", diff --git a/frontend/src/i18n/locales/ko/translation.json b/frontend/src/i18n/locales/ko/translation.json index 2771f61d0c9..ccdb6df8f07 100644 --- a/frontend/src/i18n/locales/ko/translation.json +++ b/frontend/src/i18n/locales/ko/translation.json @@ -196,8 +196,10 @@ "Navigation": "Navigation", "General": "일반", "Reset All to Defaults": "", - "Log out from all": "", + "Session expired. Logging out…": "", "Log out": "로그아웃", + "Session expires in {{time}}": "", + "Log out from all": "", "Account of current user": "현재 사용자 계정", "Appbar Tools": "Appbar Tools", "show more": "더 보기", diff --git a/frontend/src/i18n/locales/pt/translation.json b/frontend/src/i18n/locales/pt/translation.json index 5b62e092e02..2c2cea29af9 100644 --- a/frontend/src/i18n/locales/pt/translation.json +++ b/frontend/src/i18n/locales/pt/translation.json @@ -196,8 +196,10 @@ "Navigation": "Navegação", "General": "Geral", "Reset All to Defaults": "", - "Log out from all": "", + "Session expired. Logging out…": "", "Log out": "Terminar sessão", + "Session expires in {{time}}": "", + "Log out from all": "", "Account of current user": "Conta do utilizador actual", "Appbar Tools": "Ferramentas da barra de aplicação", "show more": "mostrar mais", diff --git a/frontend/src/i18n/locales/ta/translation.json b/frontend/src/i18n/locales/ta/translation.json index 9dbd1be477e..1fdf3f3d4bd 100644 --- a/frontend/src/i18n/locales/ta/translation.json +++ b/frontend/src/i18n/locales/ta/translation.json @@ -196,8 +196,10 @@ "Navigation": "நேவிகேஷன்", "General": "பொது", "Reset All to Defaults": "", - "Log out from all": "", + "Session expired. Logging out…": "", "Log out": "வெளியேறு", + "Session expires in {{time}}": "", + "Log out from all": "", "Account of current user": "தற்போதைய பயனரின் கணக்கு", "Appbar Tools": "ஆப்பார் கருவிகள்", "show more": "மேலும் காட்டு", diff --git a/frontend/src/i18n/locales/zh-tw/translation.json b/frontend/src/i18n/locales/zh-tw/translation.json index ebd38e896d2..35715388264 100644 --- a/frontend/src/i18n/locales/zh-tw/translation.json +++ b/frontend/src/i18n/locales/zh-tw/translation.json @@ -196,8 +196,10 @@ "Navigation": "導航", "General": "一般", "Reset All to Defaults": "", - "Log out from all": "", + "Session expired. Logging out…": "", "Log out": "登出", + "Session expires in {{time}}": "", + "Log out from all": "", "Account of current user": "當前使用者的使用者", "Appbar Tools": "應用欄工具", "show more": "顯示更多", diff --git a/frontend/src/i18n/locales/zh/translation.json b/frontend/src/i18n/locales/zh/translation.json index eb5d25e4d85..9d9a99fac8d 100644 --- a/frontend/src/i18n/locales/zh/translation.json +++ b/frontend/src/i18n/locales/zh/translation.json @@ -196,8 +196,10 @@ "Navigation": "导航", "General": "一般", "Reset All to Defaults": "", - "Log out from all": "", + "Session expired. Logging out…": "", "Log out": "登出", + "Session expires in {{time}}": "", + "Log out from all": "", "Account of current user": "当前用户的账户", "Appbar Tools": "应用栏工具", "show more": "显示更多", diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index e8e2c3287c6..bbc97894535 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -21,6 +21,7 @@ import { Base64 } from 'js-base64'; import { getHeadlampAPIHeaders } from '../helpers/getHeadlampAPIHeaders'; import store from '../redux/stores/store'; +import { ApiError } from './k8s/api/v2/ApiError'; import { backendFetch } from './k8s/api/v2/fetch'; import { queryClient } from './queryClient'; @@ -153,3 +154,50 @@ export function deleteTokens() { const clusters = Object.keys(store.getState().config.allClusters ?? {}); return Promise.all(clusters.map(cluster => logout(cluster))); } + +/** + * Response from the /clusters/:cluster/me endpoint. + */ +export interface ClusterMeResponse { + username?: string; + email?: string; + groups?: string[]; + userInfoURL?: string; + /** Unix timestamp (seconds) when the token expires. Present only when the backend + * can parse an `exp` claim from the token (JWT-based auth). */ + tokenExpiry?: number; +} + +/** + * Result of a call to fetchClusterMe. + * `tokenExpired` is true when the backend returned 401 (token missing or expired). + * `data` is null when the request failed for any reason other than a 401. + */ +export type ClusterMeResult = + | { tokenExpired: false; data: ClusterMeResponse } + | { tokenExpired: true; data: null } + | { tokenExpired: false; data: null }; + +/** + * Fetches identity and token-expiry information for the given cluster from the + * Headlamp backend's /clusters/:cluster/me endpoint. + * + * The backend validates the per-cluster cookie and returns 401 when the token + * is missing or has already expired — callers should treat that as a signal to + * log the user out. + * + * @param cluster - Name of the cluster. + * @returns A ClusterMeResult object. + */ +export async function fetchClusterMe(cluster: string): Promise { + try { + const response = await backendFetch(`/clusters/${cluster}/me`); + const data: ClusterMeResponse = await response.json(); + return { tokenExpired: false, data }; + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + return { tokenExpired: true, data: null }; + } + return { tokenExpired: false, data: null }; + } +} From ab771b2c0635aeed3915a0a760c19ce6b3c7f299 Mon Sep 17 00:00:00 2001 From: Prabinder Singh Date: Fri, 8 May 2026 03:48:41 +0530 Subject: [PATCH 3/8] frontend: Add Vitest test coverage for TokenExpiryNotification --- .../App/TokenExpiryNotification.test.tsx | 123 ++++++++++++++++++ frontend/src/lib/auth.test.ts | 41 +++++- .../plugin/__snapshots__/pluginLib.snapshot | 1 + 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/App/TokenExpiryNotification.test.tsx diff --git a/frontend/src/components/App/TokenExpiryNotification.test.tsx b/frontend/src/components/App/TokenExpiryNotification.test.tsx new file mode 100644 index 00000000000..f840a96cc8e --- /dev/null +++ b/frontend/src/components/App/TokenExpiryNotification.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ClusterMeResult } from '../../lib/auth'; +import { TestContext } from '../../test'; +import { PureTokenExpiryNotification } from './TokenExpiryNotification'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key.split('|')[1] || key, + }), +})); + +const { mockLogout, mockGetCluster } = vi.hoisted(() => ({ + mockLogout: vi.fn().mockResolvedValue(undefined), + mockGetCluster: vi.fn().mockReturnValue('test-cluster'), +})); + +vi.mock('../../lib/auth', async () => ({ + ...(await vi.importActual('../../lib/auth')), + logout: (...args: any[]) => mockLogout(...args), +})); + +vi.mock('../../lib/cluster', () => ({ + getCluster: () => mockGetCluster(), +})); + +vi.mock('../../lib/router/getRoute', () => ({ + getRoute: (name: string) => { + const routes: Record = { + login: { path: '/login' }, + token: { path: '/token' }, + settingsCluster: { path: '/settings' }, + }; + return routes[name] ?? null; + }, +})); + +vi.mock('../../lib/router/getRoutePath', () => ({ + getRoutePath: (route: { path: string }) => route.path, +})); + +function makeFetch(result: ClusterMeResult) { + return vi.fn().mockResolvedValue(result); +} + +describe('PureTokenExpiryNotification', () => { + beforeEach(() => { + mockLogout.mockReset().mockResolvedValue(undefined); + mockGetCluster.mockReturnValue('test-cluster'); + }); + + it('shows warning banner when token expiry is within 2 minutes', async () => { + const expiry = Math.floor(Date.now() / 1000) + 90; + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText(/Session expires in/)).toBeInTheDocument(); + }); + }); + + it('shows no warning when token expiry is more than 2 minutes away', async () => { + const expiry = Math.floor(Date.now() / 1000) + 3600; + const fetchFn = makeFetch({ tokenExpired: false, data: { tokenExpiry: expiry } }); + render( + + + + ); + + await waitFor(() => expect(fetchFn).toHaveBeenCalled()); + expect(screen.queryByText(/Session expires in/)).not.toBeInTheDocument(); + }); + + it('calls logout when the backend reports the token as expired', async () => { + render( + + + + ); + + await waitFor(() => { + expect(mockLogout).toHaveBeenCalledWith('test-cluster'); + }); + }); + + it('suppresses the banner on excluded routes', async () => { + const expiry = Math.floor(Date.now() / 1000) + 90; + const fetchFn = makeFetch({ tokenExpired: false, data: { tokenExpiry: expiry } }); + render( + + + + ); + + await waitFor(() => expect(fetchFn).toHaveBeenCalled()); + expect(screen.queryByText(/Session expires in/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Session expired/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/auth.test.ts b/frontend/src/lib/auth.test.ts index 0052c7106a2..2e4a87dca47 100644 --- a/frontend/src/lib/auth.test.ts +++ b/frontend/src/lib/auth.test.ts @@ -17,7 +17,8 @@ import { Base64 } from 'js-base64'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import store from '../redux/stores/store'; -import { getUserInfo, setToken } from './auth'; +import { fetchClusterMe, getUserInfo, setToken } from './auth'; +import { ApiError } from './k8s/api/v2/ApiError'; import { backendFetch } from './k8s/api/v2/fetch'; // Mock the dependencies @@ -163,4 +164,42 @@ describe('auth', () => { spy.mockRestore(); }); }); + + describe('fetchClusterMe', () => { + it('returns tokenExpired:false and data on 200 response', async () => { + const responseData = { username: 'alice', tokenExpiry: 9999999999 }; + mockBackendFetch.mockResolvedValue({ + json: vi.fn().mockResolvedValue(responseData), + } as any); + + const result = await fetchClusterMe('test-cluster'); + + expect(mockBackendFetch).toHaveBeenCalledWith('/clusters/test-cluster/me'); + expect(result).toEqual({ tokenExpired: false, data: responseData }); + }); + + it('returns tokenExpired:true and data:null on 401 ApiError', async () => { + mockBackendFetch.mockRejectedValue(new ApiError('Unauthorized', { status: 401 })); + + const result = await fetchClusterMe('test-cluster'); + + expect(result).toEqual({ tokenExpired: true, data: null }); + }); + + it('returns tokenExpired:false and data:null on non-401 ApiError', async () => { + mockBackendFetch.mockRejectedValue(new ApiError('Internal Server Error', { status: 500 })); + + const result = await fetchClusterMe('test-cluster'); + + expect(result).toEqual({ tokenExpired: false, data: null }); + }); + + it('returns tokenExpired:false and data:null on network error', async () => { + mockBackendFetch.mockRejectedValue(new Error('Network failed')); + + const result = await fetchClusterMe('test-cluster'); + + expect(result).toEqual({ tokenExpired: false, data: null }); + }); + }); }); diff --git a/frontend/src/plugin/__snapshots__/pluginLib.snapshot b/frontend/src/plugin/__snapshots__/pluginLib.snapshot index 05c86716518..4bcdb5a7823 100644 --- a/frontend/src/plugin/__snapshots__/pluginLib.snapshot +++ b/frontend/src/plugin/__snapshots__/pluginLib.snapshot @@ -530,6 +530,7 @@ "CLUSTER_ACTION_GRACE_PERIOD": 5000, "auth": { "deleteTokens": [Function], + "fetchClusterMe": [Function], "getToken": [Function], "getUserInfo": [Function], "hasToken": [Function], From 3f34e5bbaf70e2544c48fa1a9acc0000de1c43b5 Mon Sep 17 00:00:00 2001 From: Prabinder Singh Date: Mon, 18 May 2026 13:00:31 +0530 Subject: [PATCH 4/8] frontend: i18n: Update translation files --- frontend/src/i18n/locales/ru/translation.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/i18n/locales/ru/translation.json b/frontend/src/i18n/locales/ru/translation.json index 9007d4a18ac..898ac9d3ba0 100644 --- a/frontend/src/i18n/locales/ru/translation.json +++ b/frontend/src/i18n/locales/ru/translation.json @@ -196,8 +196,10 @@ "Navigation": "Навигация", "General": "Общий", "Reset All to Defaults": "Сбросить все к настройкам по умолчанию", - "Log out from all": "Выйти из всех", + "Session expired. Logging out…": "", "Log out": "Выйти", + "Session expires in {{time}}": "", + "Log out from all": "Выйти из всех", "Account of current user": "Аккаунт текущего пользователя", "Appbar Tools": "Инструменты панели приложений", "show more": "показать больше", From 59cbf6ed4abd32d567736b4d38b14f7ef2649227 Mon Sep 17 00:00:00 2001 From: Prabinder Singh Date: Mon, 18 May 2026 13:18:20 +0530 Subject: [PATCH 5/8] frontend: auth: Simplify ClusterMeResult discriminated union for correct TS narrowing --- frontend/src/lib/auth.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index bbc97894535..0fbfd3ec5ec 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -174,9 +174,8 @@ export interface ClusterMeResponse { * `data` is null when the request failed for any reason other than a 401. */ export type ClusterMeResult = - | { tokenExpired: false; data: ClusterMeResponse } - | { tokenExpired: true; data: null } - | { tokenExpired: false; data: null }; + | { tokenExpired: false; data: ClusterMeResponse | null } + | { tokenExpired: true; data: null }; /** * Fetches identity and token-expiry information for the given cluster from the From da9534fce704f4b16632844e93ab3f4826cfdf1f Mon Sep 17 00:00:00 2001 From: Prabinder Singh Date: Mon, 18 May 2026 22:01:32 +0530 Subject: [PATCH 6/8] frontend: TokenExpiryNotification: Stop polling immediately on token expiry --- .../App/TokenExpiryNotification.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/App/TokenExpiryNotification.tsx b/frontend/src/components/App/TokenExpiryNotification.tsx index 31aab20542f..c30d31c0746 100644 --- a/frontend/src/components/App/TokenExpiryNotification.tsx +++ b/frontend/src/components/App/TokenExpiryNotification.tsx @@ -59,8 +59,14 @@ export function PureTokenExpiryNotification({ // Tracks wall-clock seconds; updated every second once we have an expiry to count down. const [now, setNow] = React.useState(() => Math.floor(Date.now() / 1000)); + // Refs so the async check closure always sees the latest expired state and can + // cancel the interval without waiting for a React re-render. + const tokenExpiredRef = React.useRef(false); + const intervalRef = React.useRef | null>(null); + // Restart the poller whenever the cluster changes. React.useEffect(() => { + tokenExpiredRef.current = false; setTokenExpiry(null); setTokenExpired(false); setNow(Math.floor(Date.now() / 1000)); @@ -73,12 +79,17 @@ export function PureTokenExpiryNotification({ let mounted = true; const check = async () => { - if (!mounted) return; + if (!mounted || tokenExpiredRef.current) return; const result = await fetchClusterMeFn(cluster); - if (!mounted) return; + if (!mounted || tokenExpiredRef.current) return; if (result.tokenExpired) { + tokenExpiredRef.current = true; setTokenExpired(true); + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } } else if (result.data?.tokenExpiry !== null && result.data?.tokenExpiry !== undefined) { setTokenExpiry(result.data.tokenExpiry); } @@ -86,11 +97,14 @@ export function PureTokenExpiryNotification({ // Run once immediately, then on the regular interval. check(); - const id = setInterval(check, POLL_INTERVAL_MS); + intervalRef.current = setInterval(check, POLL_INTERVAL_MS); return () => { mounted = false; - clearInterval(id); + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } }; }, [clusterName, fetchClusterMeFn]); @@ -146,7 +160,7 @@ export function PureTokenExpiryNotification({ return true; }, [pathname]); - if (!showOnRoute || !getCluster()) { + if (!showOnRoute || !clusterName) { return null; } From e7704f0d67636e2b361c62729694edf2f98c487f Mon Sep 17 00:00:00 2001 From: Prabinder Singh Date: Mon, 18 May 2026 22:03:37 +0530 Subject: [PATCH 7/8] frontend: auth: Mark fetchClusterMe as internal non-plugin API --- frontend/src/lib/auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 0fbfd3ec5ec..b4fb39d4454 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -188,6 +188,7 @@ export type ClusterMeResult = * @param cluster - Name of the cluster. * @returns A ClusterMeResult object. */ +/** @internal — exported for testability via injectable fetchClusterMeFn parameter only. Not a stable plugin API. */ export async function fetchClusterMe(cluster: string): Promise { try { const response = await backendFetch(`/clusters/${cluster}/me`); From d20e4c17c1191486b55a87834d9387cc6c86bd19 Mon Sep 17 00:00:00 2001 From: Prabinder Singh Date: Wed, 20 May 2026 00:44:22 +0530 Subject: [PATCH 8/8] frontend: TokenExpiryNotification: Fix ref sync and clamp setTimeout delay --- frontend/src/components/App/TokenExpiryNotification.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/App/TokenExpiryNotification.tsx b/frontend/src/components/App/TokenExpiryNotification.tsx index c30d31c0746..b11019710d9 100644 --- a/frontend/src/components/App/TokenExpiryNotification.tsx +++ b/frontend/src/components/App/TokenExpiryNotification.tsx @@ -122,11 +122,11 @@ export function PureTokenExpiryNotification({ return () => clearInterval(intervalId!); } - const delay = (secondsLeft - WARNING_BEFORE_EXPIRY_SECONDS) * 1000; + const safeDelay = Math.min((secondsLeft - WARNING_BEFORE_EXPIRY_SECONDS) * 1000, 2147483647); const timeoutId = setTimeout(() => { setNow(Math.floor(Date.now() / 1000)); intervalId = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000); - }, delay); + }, safeDelay); return () => { clearTimeout(timeoutId); @@ -138,6 +138,11 @@ export function PureTokenExpiryNotification({ // than waiting up to POLL_INTERVAL_MS after the token has already expired. React.useEffect(() => { if (tokenExpiry !== null && now >= tokenExpiry && !tokenExpired) { + tokenExpiredRef.current = true; + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } setTokenExpired(true); } }, [now, tokenExpiry, tokenExpired]);