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")
+}
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.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/components/App/TokenExpiryNotification.tsx b/frontend/src/components/App/TokenExpiryNotification.tsx
new file mode 100644
index 00000000000..b11019710d9
--- /dev/null
+++ b/frontend/src/components/App/TokenExpiryNotification.tsx
@@ -0,0 +1,283 @@
+/*
+ * 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));
+
+ // 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));
+
+ const cluster = clusterName;
+ if (!cluster) {
+ return;
+ }
+
+ let mounted = true;
+
+ const check = async () => {
+ if (!mounted || tokenExpiredRef.current) return;
+ const result = await fetchClusterMeFn(cluster);
+ 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);
+ }
+ };
+
+ // Run once immediately, then on the regular interval.
+ check();
+ intervalRef.current = setInterval(check, POLL_INTERVAL_MS);
+
+ return () => {
+ mounted = false;
+ if (intervalRef.current !== null) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ };
+ }, [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 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);
+ }, safeDelay);
+
+ 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) {
+ tokenExpiredRef.current = true;
+ if (intervalRef.current !== null) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ 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 || !clusterName) {
+ 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>home1>.": "",
- "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>home1>.": "<1>होम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/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": "показать больше",
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.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/lib/auth.ts b/frontend/src/lib/auth.ts
index e8e2c3287c6..b4fb39d4454 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 | null }
+ | { tokenExpired: true; 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.
+ */
+/** @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`);
+ 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 };
+ }
+}
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],